Swift - RxSwift的使用详解65(表格图片加载优化)
一、问题描述
(1)带有图片的列表单元格在开发中十分常见。通常我们可以直接在 tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 中给单元格设置图片。 但有时这些图片需要从远程加载,或者还要给图片作裁减,添加滤镜等操作(比如下面给所有图片添加了棕褐色滤镜)。
(2)如果这些操作我们直接放在主线程中进行,当上下拖动表格滚动条时,由于单元格渲染是实时进行的。那么单元格便会不断地进行图片加载,渲染操作,从而影响效率造成卡顿。 而且单元格即使被滑出当前视图,它里面的图片还是会继续加载处理,浪费流量。我在之前也写过文章介绍如何对其进行优化:
(3)之前的方案是使用纯 Swift 写的,我们需要手动创建一个后台线程去维护下载队列、以及处理图片。而如果项目使用 RxSwift 的话,这些操作就变得十分简单了。
二、使用 RxSwift 优化表格图片的加载
1,运行效果
(1)图片的加载,以及给图片添加滤镜这些操作会自动转到后台线程中进行,等处理完毕后再回到前台显示,避免前台卡顿。
(2)同时即使当前 cell 的图片已经在加载或者处理中,只要它离开了可视区域,那么这个操作也会自动取消。
2,样例代码
(1)movies.plist(存储电影名字和对应的海报 url 地址)
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <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>解救吾先生</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2267511583.jpg</string> <key>暗杀</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2265025290.jpg</string> <key>诡打墙</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2266127649.jpg</string> <key>像素大战</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2262297012.jpg</string> <key>逆转胜</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2266298839.jpg</string> <key>诱狼</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2263788342.jpg</string> <key>这里的黎明静悄悄</key> <string>http://img3.doubanio.com/view/movie_poster_cover/mpst/public/p2262316531.jpg</string> <key>hangge.com</key> <string>http://xxxxxx</string> </dict> </plist>
(2)MyTableCell.swift(自定义单元格)
import UIKit import RxSwift import RxCocoa //单元格类 class MyTableCell: UITableViewCell { var disposeBag: DisposeBag? //当前显示的图片地址 var imageURL:URL? { didSet{ let disposeBag = DisposeBag() //保证 cell 被重用的时候不会被多次订阅 //订阅序列 Observable.of(imageURL) .observeOn(ConcurrentDispatchQueueScheduler(qos: .userInitiated))//转后台 .flatMap { self.fetchImage($0) } //下载电影图片 .flatMap { self.applySepiaFilter($0) } //给图片添加滤镜 .observeOn(MainScheduler.instance) //回到主线程显示图片 .subscribe(onNext: { //显示图片 self.imageView?.image = $0 //图像视图可能已经因为新图像而改变了尺寸,所以需要重新调整单元格的布局 self.setNeedsLayout() }) .disposed(by: disposeBag) //cell离开可视区域后自动取消订阅 self.disposeBag = disposeBag } } //单元格重用时调用 override func prepareForReuse() { super.prepareForReuse() disposeBag = nil //保证 cell 被重用的时候不会被多次订阅 } //初始化 override init(style: UITableViewCellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } //获取图片 func fetchImage(_ imageURL:URL?) -> Observable<UIImage?>{ var unfilteredImage: UIImage? if let imageURL = imageURL, let imageData = try? Data(contentsOf: imageURL) { unfilteredImage = UIImage(data:imageData) } return Observable.of(unfilteredImage) } //给图片添加棕褐色滤镜 func applySepiaFilter(_ image:UIImage?) -> Observable<UIImage> { if let image = image { 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 Observable.of(UIImage(cgImage: outImage!)) } } return Observable.of(UIImage(named: "failed")!) //未加载到海报显示默认的“暂无图片” } }
(3)ViewController.swift(主视图控制器)
import UIKit import RxSwift import RxCocoa class ViewController: UIViewController { //图片数据源地址 let dataSourcePath = Bundle.main.path(forResource: "movies", ofType: "plist") //电影图片字典集合(使用了懒加载) lazy var movies = NSDictionary(contentsOfFile: dataSourcePath!)! var tableView:UITableView! let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() //创建表格视图 self.tableView = UITableView(frame: self.view.frame, style:.plain) //创建一个重用的单元格 self.tableView!.register(MyTableCell.self, forCellReuseIdentifier: "Cell") //单元格无法选中 self.tableView.allowsSelection = false //设置单元格高度 self.tableView!.rowHeight = 100 self.view.addSubview(self.tableView!) //初始化数据 let items = Observable.just(movies) //设置单元格数据(其实就是对 cellForRowAt 的封装) items .bind(to: tableView.rx.items) { (tableView, row, element) in //初始化cell let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! MyTableCell //设置单元格标题 cell.textLabel?.text = "\(element.key)" //设置单元格图片 cell.imageURL = URL(string:element.value as! String) //返回单元格 return cell } .disposed(by: disposeBag) } }
三、功能改进
上面的样例我们只是简单地将图片的加载与滤镜添加放到后台线程去处理,等处理完毕后再回到主线程中显示。这样做虽然不会卡顿了,但由于没有对已下载的图片做缓存,那么来回滚动表格时,图片仍会重复加载,造成流量浪费。下面再次对该功能进行优化。
1,运行效果
(1)如果某个电影海报的图片之前已经下载或者处理过了,则将其保存下来,下次直接使用不用再次请求。
(2)当我们滑动表格时,进入可视区域的 cell 并不会立刻去获取 image,而是等待 0.3 秒再去获取。因为当表格快速滚动过程中,很多 cell 只是短暂的出现一下,没必要去发起请求。
(3)图片加载前会显示一个表示正在加载的占位图片,加载失败的话也会显示个表示失败的图片。
(4)同时加载过程中,单元格尾部显示一个活动指示器,加载结束后会自动消失。
2,样例代码
(1)MovieRecord.swift(数据模型)
import UIKit // 这个枚举包含所有电影图片的状态 enum MovieRecordState { case new, downloaded, filtered, failed } // 电影条目类 class MovieRecord { let name:String //电影标题 let url:URL //电影海报图片地址 var image:UIImage? //电影海报图片 var state = MovieRecordState.new //图片当前状态 init(name:String, url:URL) { self.name = name self.url = url } }
(2)MyTableCell.swift(自定义单元格)
import UIKit import RxSwift import RxCocoa //单元格类 class MyTableCell: UITableViewCell { var disposeBag: DisposeBag? //单元格尾部的活动指示器 var indicator:UIActivityIndicatorView! //当前显示的数据 var movieRecord:MovieRecord! { didSet{ let disposeBag = DisposeBag() //保证 cell 被重用的时候不会被多次订阅 //设置标题 self.textLabel?.text = movieRecord.name //先判断图片当前的状态 if movieRecord.state == .filtered || movieRecord.state == .failed { //之前已经处理完毕的就直接显示出来 self.imageView?.image = movieRecord.image }else{ //只要图片状态是没出处理完毕的,一律先显示个占位符图片 self.imageView?.image = UIImage(named: "placeholder") //显示活动指示器 self.indicator.startAnimating() //订阅序列 Observable.of(movieRecord) .delay(0.3, scheduler: MainScheduler.instance) //延迟0.3秒 .observeOn(ConcurrentDispatchQueueScheduler(qos: .userInitiated))//后台 .flatMap { return self.fetchImage($0) } //下载电影图片 .flatMap { self.applySepiaFilter($0) } //给图片添加滤镜 .observeOn(MainScheduler.instance) //回到主线程显示图片 .subscribe(onNext: { self.imageView?.image = $0.image self.indicator.stopAnimating() }) .disposed(by: disposeBag) //cell离开可视区域后自动取消订阅 } self.disposeBag = disposeBag } } //单元格重用时调用 override func prepareForReuse() { super.prepareForReuse() disposeBag = nil //保证 cell 被重用的时候不会被多次订阅 } //初始化 override init(style: UITableViewCellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) //为了提示用户,将cell的accessory view设置为UIActivityIndicatorView。 indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) self.accessoryView = indicator } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } //获取图片 func fetchImage(_ movie:MovieRecord) -> Observable<MovieRecord> { //如果当前的电影条目还未获取图片则去获取图片 if movie.state == .new { if let imageData = try? Data(contentsOf: movie.url){ movie.image = UIImage(data:imageData) movie.state = .downloaded //图片状态改成下载成功 }else{ movie.image = UIImage(named: "failed")! //未加载到海报显示默认的“暂无图片” movie.state = .failed //图片状态改成失败 } } return Observable.of(movie) } //给图片添加棕褐色滤镜 func applySepiaFilter(_ movie:MovieRecord) -> Observable<MovieRecord> { //只有刚下载的图片需要处理 if movie.state == .downloaded { let inputImage = CIImage(data:UIImagePNGRepresentation(movie.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) movie.image = UIImage(cgImage: outImage!) movie.state = .filtered //图片状态改成添加滤镜成功 } else { movie.image = UIImage(named: "failed")! //处理失败的海报显示默认的“暂无图片” movie.state = .failed //图片状态改成失败 } } return Observable.of(movie) } }
(3)ViewController.swift(主视图控制器)
import UIKit import RxSwift import RxCocoa class ViewController: UIViewController { //电影条目数据 var movies = [MovieRecord]() var tableView:UITableView! let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() //创建表格视图 self.tableView = UITableView(frame: self.view.frame, style:.plain) //创建一个重用的单元格 self.tableView!.register(MyTableCell.self, forCellReuseIdentifier: "Cell") //单元格无法选中 self.tableView.allowsSelection = false //设置单元格高度 self.tableView!.rowHeight = 100 self.view.addSubview(self.tableView!) //数据源地址 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) } } //设置单元格数据(其实就是对 cellForRowAt 的封装) Observable.just(movies) .bind(to: tableView.rx.items) { (tableView, row, element) in //初始化cell let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! MyTableCell //设置单元格数据 cell.movieRecord = element //返回单元格 return cell } .disposed(by: disposeBag) } }