前言
知道VVeboTableViewDemo其實很久了,一直想研究一下,最近終于有時間了,將VVeboTableViewDemo
用Swift做了一遍(VVeboTableViewDemo.swift),花了兩個周對iOS優化的一系列文章通讀了至少一遍,發現它們對優化的點總結的很散,而且大多不適合我這樣的小菜。
列如這樣的問題:
- 為什么需要60fps?
- 為什么要減少混合?
- 為什么要避免離屏渲染?
- UIView和CALayer的關系?
- 為什么在4之后Twitter的繪制方案不能提升性能了?
......
在讀完一篇關于iOS的優化文章后并不知道這些問題的根本,只知道要這樣做。因此,我想把這些問題總結一下,對有用信息進行過濾,以減少大家的學習時間成本,讓更多像我這樣的iOS小菜也知道如何優化。在文中,我也會推薦相應的技術博客,讓你花最少的成本,掌握某項技術。當然,由于知識結構有限,我get到的點可能有誤,希望有誤的地方你可以指出,我會及時修正。
好了,讓我們開始吧。
VVeboTableViewDemo源碼分析
本節關鍵字
- Core Graphics
- Core Text
- 異步繪制
首先看一下VVeboTableViewDemo的結構(由于我已經把它翻譯成了Swift,我下面是用Swift版分析的,和原版的邏輯是一致的。)
其中DataPrenstenter
是我從VVeboTableView
中抽離出來的,他其實就是讀取數據的,你不用關心。
核心類
- VVeboLabel(這里面主要使用了本節關鍵字提到的三種技術)
- VVeboTableViewCell(這里面主要使用異步繪制技術)
- VVeboTableView(這里的主要作用是控制了數據繪制的時機,當用戶快速滑動時,數據是不會繪制的)
VVeboLabel
以上這張圖是VVeboLabel
中所有的內容,高亮的那個方法是VVeboLabel
的核心所在。
highlightImageView
用于顯示繪制text的圖片highlightImageView
用于顯示繪制text高亮時的圖片,會疊在labelImageView上面** func textDidSet(_ : , oldText: ) // 核心方法**
// 核心方法
func textDidSet(_ text: String?, oldText: String?) {
// 當 text為nil或者是empty,加labelImageView和highlightImageView設置為nil,結束
guard let text = text, !text.isEmpty else {
labelImageView.image = nil
highlightImageView.image = nil
return
}
if text == oldText {
if !highlighting || currentRange.location == -1 {
return
}
}
if highlighting && labelImageView.image == nil {
return
}
if !highlighting {
framesDict.removeAll()
currentRange = NSRange(location: -1, length: -1)
}
let flag = drawFlag
let isHighlight = highlighting
// 將文本繪制放入全局隊列,以減輕主線程壓力
DispatchQueue.global().async {
let temp = text
var size = self.frame.size
size.height += 10
// 如果有顏色繪制將會繪制顏色
let isNotClear = self.backgroundColor != .clear
/// 第一個參數表示所要創建的圖片的尺寸;
/// 第二個參數用來指定所生成圖片的背景是否為不透明,如上我們使用true而不是false,則我們得到的圖片背景將會是黑色,顯然這不是我想要的;
/// 第三個參數指定生成圖片的縮放因子,這個縮放因子與UIImage的scale屬性所指的含義是一致的。傳入0則表示讓圖片的縮放因子根據屏幕的分辨率而變化,所以我們得到的圖片不管是在單分辨率還是視網膜屏上看起來都會很好。
/// 注意這個與UIGraphicsEndImageContext()成對出現
/// iOS10 中新增了UIGraphicsImageRenderer(bounds: _)
UIGraphicsBeginImageContextWithOptions(size, isNotClear, 0)
/// 獲取繪制畫布
/// 每一個UIView都有一個layer,每一個layer都有個content,這個content指向的是一塊緩存,叫做backing store。
/// UIView的繪制和渲染是兩個過程,當UIView被繪制時,CPU執行drawRect,通過context將數據寫入backing store
/// http://vizlabxt.github.io/blog/2012/10/22/UIView-Rendering/
guard let context = UIGraphicsGetCurrentContext() else { return }
if isNotClear {
/// 這句相當于這兩句
/// self.backgroundColor?.setFill() 設置填充顏色
/// self.backgroundColor?.setStroke() 設置邊框顏色
self.backgroundColor?.set()
/// 繪制一個實心矩形
/// stroke(_ rect: CGRect) 用這個方法得到的是邊框為你設置顏色的空心矩形
context.fill(CGRect(origin: .zero, size: size))
}
/// 坐標反轉,固定寫法,因為Core Text中坐標起點是左下角
context.textMatrix = .identity
context.translateBy(x: 0, y: size.height) //向上平移
context.scaleBy(x: 1.0, y: -1.0) //在y軸縮放-1相當于沿著x張旋轉180
//MARK: - 這里屬于 Core Text技術
//Set line height, font, color and break mode
var minimumLineHeight = self.font.pointSize
var maximumLineHeight = minimumLineHeight
var linespace = self.lineSpace
let font = CTFontCreateWithName(self.font.fontName as CFString?, self.font.pointSize, nil)
var lineBreakMode = CTLineBreakMode.byWordWrapping
var alignment = CTTextAlignmentFromUITextAlignment(self.textAlignment)
//Apply paragraph settings
let alignmentSetting = [
CTParagraphStyleSetting(spec: .alignment, valueSize: MemoryLayout.size(ofValue: alignment), value: &alignment),
CTParagraphStyleSetting(spec: .minimumLineHeight, valueSize: MemoryLayout.size(ofValue: minimumLineHeight), value: &minimumLineHeight),
CTParagraphStyleSetting(spec: .maximumLineHeight, valueSize: MemoryLayout.size(ofValue: maximumLineHeight), value: &maximumLineHeight),
CTParagraphStyleSetting(spec: .maximumLineSpacing, valueSize: MemoryLayout.size(ofValue: linespace), value: &linespace),
CTParagraphStyleSetting(spec: .minimumLineSpacing, valueSize: MemoryLayout.size(ofValue: linespace), value: &linespace),
CTParagraphStyleSetting(spec: .lineBreakMode, valueSize: MemoryLayout.size(ofValue: 1), value: &lineBreakMode)
]
let style = CTParagraphStyleCreate(alignmentSetting, alignmentSetting.count)
let attributes: [String: Any] = [
NSFontAttributeName: font,
NSForegroundColorAttributeName: self.textColor.cgColor,
NSParagraphStyleAttributeName: style
]
//Create attributed string, with applied syntax highlighting
let attributedStr = NSMutableAttributedString(string: text, attributes: attributes)
// 通過正則匹配出需要高亮的子串,設置對應的屬性
let attributedString: CFAttributedString = self.highlightText(attributedStr)
//Draw the frame
// 生成framesetter
// 通過CFAttributedString(NSAttributeString 也可以無縫橋接)進行初始化
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
let rect = CGRect(x: 0, y: 5, width: size.width, height: size.height - 5)
// 這里應該不需要,因為在Swift中text為let
// guard temp == text else { return }
// 確保行高一致,計算所需觸摸區域
// 這里采用的是逐行繪制,因為emoji需要特殊處理(文本高度和間隔不一致)
self.draw(framesetter: framesetter, attributedString: attributedStr, textRange: CFRangeMake(0, text.length), in: rect, context: context)
// ???: 上面已經反轉
// context.textMatrix = .identity
// context.translateBy(x: 0, y: size.height) //向上平移
// context.scaleBy(x: 1.0, y: -1.0)
// 新繪制的圖
let screenShotimage = UIGraphicsGetImageFromCurrentImageContext()
let shotImageSize = screenShotimage?.size ?? .zero
// 結束繪制
UIGraphicsEndImageContext()
/// 回到主線程設置繪制文本的圖片
DispatchQueue.main.async {
attributedStr.mutableString.setString("")
guard self.drawFlag == flag else { return }
if isHighlight { //點擊高亮進入
guard self.highlighting else { return }
self.highlightImageView.image = nil
if self.highlightImageView.frame.width != shotImageSize.width {
self.highlightImageView.frame.size.width = shotImageSize.width
}
if self.highlightImageView.frame.height != shotImageSize.height {
self.highlightImageView.frame.size.height = shotImageSize.height
}
self.highlightImageView.image = screenShotimage
} else { //默認狀態
guard temp == text else { return }
if self.labelImageView.frame.width != shotImageSize.width {
self.labelImageView.frame.size.width = shotImageSize.width
}
if self.labelImageView.frame.height != shotImageSize.height {
self.labelImageView.frame.size.height = shotImageSize.height
}
self.highlightImageView.image = nil
self.labelImageView.image = nil
self.labelImageView.image = screenShotimage
}
// self.debugDraw() // 繪制可觸摸區域,主要用于調試
}
}
}
- **func draw(framesetter: CTFramesetter, attributedString: NSAttributedString, textRange: CFRange, in rect: CGRect, context: CGContext) **
這里屬于Core Text技術,主要是對文本的特殊處理,采用了逐行繪制
其余方法主要是對文本高亮和清除內容處理,不是重點,可以不關心。
VVeboTableViewCell
在VVeboTableViewCell
中,高亮的方法為核心部分。其實同VVeboLabel
的思想是一模一樣的,就是將內容異步繪制在一張圖上,然后顯示出來,到達減少混合,以減小GPU壓力。就不貼出源碼,下面會放出Demo。
VVeboTableView
這是一個設計很巧妙的類,在開始研究這個類的思路之前,我建議你看看這篇文章。當然如果你對UIScrollView
足夠熟悉,并且熟悉這個方法func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
,那么對VVeboTableView
的思路可以一目了然了。
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
該方法從 iOS 5 引入,在 didEndDragging
前被調用,當 willEndDragging
方法中velocity
為 CGPoin.zero
(結束拖動時兩個方向都沒有速度)時,didEndDragging
中的 decelerate
為 false,即沒有減速過程,willBeginDecelerating
和 didEndDecelerating
也就不會被調用。反之,當 velocity 不為 CGPoin.zero
時,scroll view 會以 velocity 為初速度,減速直到 targetContentOffset
。值得注意的是,這里的 targetContentOffset 是個指針,沒錯,你可以改變減速運動的目的地,這在一些效果的實現時十分有用。
以上文字來源
微信讀書的那種橫滑居中效果,除了重寫UICollectionViewFlowLayout
,
也通過控制targetContentOffset就可以實現
圖中高亮方法為核心部分
//按需加載 - 如果目標行與當前行相差超過指定行數,只在目標滾動范圍的前后指定3行加載。
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
guard let cip = indexPathsForVisibleRows?.first,
let ip = indexPathForRow(at: CGPoint(x: 0, y: targetContentOffset.move().y))
else { return }
let skipCount = 8
// 快速滑動時,顯示的第一個與停止位置的那個Cell間隔超過8
guard labs(cip.row - ip.row) > skipCount else { return }
let temp = indexPathsForRows(in: CGRect(x: 0, y: targetContentOffset.move().y, width: frame.width, height: frame.height))
var arr = [temp]
if velocity.y < 0 { // 下滑動
if let indexPath = temp?.last, indexPath.row + 3 < datas.count {
(1...3).forEach() {
arr.append([IndexPath(row: indexPath.row + $0, section: 0)])
}
}
} else { // 上滑動
if let indexPath = temp?.first, indexPath.row > 3 {
(1...3).reversed().forEach() {
arr.append([IndexPath(row: indexPath.row - $0, section: 0)])
}
}
}
for item in arr {
guard let item = item else { continue }
for indexPath in item {
needLoadArr.append(indexPath)
}
}
}
cell繪制判斷邏輯
func draw(cell: VVeboTableViewCell, with indexPath: IndexPath) {
let data = datas[indexPath.row]
cell.selectionStyle = .none
cell.clear()
cell.data = data
// needLoadArr不為空,說明用戶有快速滑動。當needLoadArr不為空時,不在其中的cell也是需要繪制的
// 因為在scrollViewWillEndDragging(_: UIScrollView, withVelocity: CGPoint,: UnsafeMutablePointer<CGPoint>)調用之后,tableView(_: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell是會繼續執行的。
// 如果單純判斷needLoadArr不為空,會導致之后的不能繪制
if !needLoadArr.isEmpty && !needLoadArr.contains(indexPath) {
cell.clear()
return
}
// 向上滾動過程不繪制
if scrollToToping {
return
}
cell.draw()
}
尾巴
以上VVeboTableViewDemo
源碼已經全部解析完成了,那么你在驚嘆作者巧妙思路的同時,肯定也很想知道這種技術的來源,和改進過程。(以下為個人猜想)
通過本文,我覺得應該了解Core Text、Core Graphics、Hit-Test View、異步繪制這幾項內容,你可以通過以下推薦的文章來掌握前三種技術,異步繪制在下一節YYAsyncLayer源碼分析中,我相信你不知不覺就掌握了這項技術。
異步繪制技術發展過程猜想
最初來源
這種技術的出現是為了減輕GPU的壓力,因為圖層的混合是GPU做的,而在這是CPU幾乎是沒事可做的,所以吧GPU的混合移到CPU的func draw(_ rect: CGRect)
去完成需求。
此技術的demo fastscrolling
技術淘汰原因
由于retina屏幕的出現,原來單位面積的像素增加,而CPU做的事情也變得多了起來,導致效率反而不及subViews方法。
AsyncDisplayKit YYKit等新技術出現
我覺得VVeboTableViewDemo的出現應該也是遵循以上過程的
推薦文章:
Core Text:
Swift之CoreText排版神器
官方文檔
Core Graphics:
iOS繪圖教程
Swift之你應該懂點Core Graphics
官方Demo
官方Demo Swift版本
Building Concurrent User Interfaces on iOS
響應鏈
iOS事件響應鏈中Hit-Test View的應用
iOS 事件處理 | Hit-Testing
異步繪制
http://www.appcoda.com/ios-concurrency/