# Swift 繪圖板功能完善以及終極優化
## 前文總結
接著這篇:[Swift 全功能的繪圖板開發](http://blog.csdn.net/zhangao0086/article/details/43836789),雖然在上一篇中我們已經完成了這些功能:
* 支持鉛筆繪圖(畫點)
* 支持畫直線
* 支持一些簡單的圖形(矩形、圓形等)
* 做一個真正的橡皮擦
* 能設置畫筆的粗細
* 能設置畫筆的顏色
* 能設置背景色或者背景圖
但是還有一個非常重要的功能沒有實現,沒錯,那就是 Undo/Redo!我之所以把這個功能單獨放出來是有原因的,一是因為上一篇已經篇幅太長,不適合繼續往上加內容;二是因為為了實現 Undo/Redo 功能,我們需要對 DrawingBoard 進行一些重構,在這篇文章中,你能看到用另一種方式實現的繪圖板。
實現的效果:?

* * *
## 更新 ViewController
先添加兩張按鈕圖:?
??
黑底、50%的透明度,箭頭用白色。?
(PS:這可是我自己做的,別嫌棄)?
圖片放到 Images.xcasserts 里:?
?
(再次PS:圖嫌小的話,就放在2x上)?
然后在 Storyboard 里添加兩個 Button:?
?
注意里面的紅框,Button 與 Board 平級,并且在 Board 的上方。?
Button 的約束如下:
1. 分別為左、右的 Button 設置 Undo、Redo 的 Image
2. 左邊的 Undo 按鈕離左 10px,頂部距離父視圖 74px
3. 右邊的 Redo 按鈕離右 10px,頂部與 Undo 相同
4. 不要設置寬、高約束,應與 Image 一致
兩個按鈕的點擊事件連接到 VC 里:
~~~
@IBAction func undo(sender: UIButton) {
self.board.undo()
}
@IBAction func redo(sneder: UIButton) {
self.board.redo()
}
~~~
(此時的 Board 還沒有 undo/redo 方法,你可以自行添加或者稍后再添加)?
兩個按鈕本身也連接到 VC 里:
~~~
@IBOutlet var undoButton: UIButton!
@IBOutlet var redoButton: UIButton!
~~~
更新我們原`viewDidLoad`中的動畫方法,使兩個 Button 也適時的隱藏及顯示:
~~~
...
self.board.drawingStateChangedBlock = {(state: DrawingState) -> () in
if state != .Moved {
UIView.beginAnimations(nil, context: nil)
if state == .Began {
self.topViewConstraintY.constant = -self.topView.frame.size.height
self.toolbarConstraintBottom.constant = -self.toolbar.frame.size.height
self.topView.layoutIfNeeded()
self.toolbar.layoutIfNeeded()
self.undoButton.alpha = 0 // 新增
self.redoButton.alpha = 0 // 新增
} else if state == .Ended {
UIView.setAnimationDelay(1.0)
self.topViewConstraintY.constant = 0
self.toolbarConstraintBottom.constant = 0
self.topView.layoutIfNeeded()
self.toolbar.layoutIfNeeded()
self.undoButton.alpha = 1 // 新增
self.redoButton.alpha = 1 // 新增
}
UIView.commitAnimations()
}
}
...
~~~
* * *
## 更新 Board
Undo/Redo 真正的邏輯都在`Board`?里面,我打算用圖片棧保存 DrawingBoard 的每一張圖,當 Undo/Redo 的時候直接把前一個狀態取出并顯示,為了分別存儲 Undo/Redo 操作所用的圖片,我們要建立兩個圖片棧:
~~~
private var undoImages = [UIImage]()
private var redoImages = [UIImage]()
~~~
然后加兩個工具方法:`canUndo`?和?`canRedo`?:
~~~
var canUndo: Bool {
get {
return self.undoImages.count > 0 || self.image != nil
}
}
var canRedo: Bool {
get {
return self.redoImages.count > 0
}
}
~~~
然后是 undo/redo 這兩個主要方法:
~~~
func undo() {
if self.canUndo == false {
return
}
if self.undoImages.count > 0 {
self.redoImages.append(self.image!)
let lastImage = self.undoImages.removeLast()
self.image = lastImage
} else if self.image != nil {
self.redoImages.append(self.image!)
self.image = nil
}
self.realImage = self.image
}
func redo() {
if self.canRedo == false {
return
}
if self.redoImages.count > 0 {
if self.image != nil {
self.undoImages.append(self.image!)
}
let lastImage = self.redoImages.removeLast()
self.image = lastImage
self.realImage = self.image
}
}
~~~
然后在每次畫新圖的時候保存下當前狀態:
~~~
private func drawingImage() {
if let brush = self.brush {
// hook
if let drawingStateChangedBlock = self.drawingStateChangedBlock {
drawingStateChangedBlock(state: self.drawingState)
}
UIGraphicsBeginImageContext(self.bounds.size)
let context = UIGraphicsGetCurrentContext()
UIColor.clearColor().setFill()
UIRectFill(self.bounds)
CGContextSetLineCap(context, kCGLineCapRound)
CGContextSetLineWidth(context, self.strokeWidth)
CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor)
if let realImage = self.realImage {
realImage.drawInRect(self.bounds)
}
brush.strokeWidth = self.strokeWidth
brush.drawInContext(context)
CGContextStrokePath(context)
let previewImage = UIGraphicsGetImageFromCurrentImageContext()
if self.drawingState == .Ended || brush.supportedContinuousDrawing() {
self.realImage = previewImage
}
UIGraphicsEndImageContext()
// === 新增 ===
if self.drawingState == .Began {
self.redoImages = []
if self.image != nil {
self.undoImages.append(self.image!)
}
}
// ======
self.image = previewImage
brush.lastPoint = brush.endPoint
}
}
~~~
這里面都有對?`self.image`?進行非空處理,其實原來不用這么麻煩,如果`Swift`?的數組支持插入`Optional`類型的話,我們直接把`self.image`插入到數組中,用的時候再取出來即可,因為 UIImageView 的 UIImage 是 Optional 類型的,賦一個 nil 給它沒有問題,就當是 undo 到初始化狀態了,但是偏偏`Swift`的數組不支持插入`Optional`類型,這就導致我們不能記住 UIImageView 的初始化狀態,只能通過判斷它的`image`是否為 nil 來處理。
完成的邏輯很簡單:當畫圖開始的時候,保存當前 image 到 undo 棧中,并清空 redo 棧,進行 undo 操作的時候,能一直 undo,并將 undo 的 image 存進 redo 棧中,直到 self.image 為 nil。從這個邏輯可以看出兩點:redo 功能非常依賴 undo,畢竟沒有撤消就沒有重做;除此之外,當用戶開始繪制新圖的時候,我們也要清空 redo 棧,因為用戶已經“回不去”了。
完成這些工作后,就能測試 Undo/Redo 功能了~?

* * *
## 關于內存的使用
我們很快地就加上了 Undo/Redo 功能,是吧? 通過維護兩個圖片棧,在進行相應的操作的時候,直接對 self.image 進行賦值,但是這么做有一個很明顯的弊端,就是內存使用毫無上限! 你可以很輕松地在 5s 上使內存使用達到 50M 甚至 100M,雖然我們做了一些處理,如當用戶繪制新圖時,清空 Redo 的圖片棧,但是這并不能從根本上解決問題。
要從根本上解決問題有兩種方式。
* * *
### 1\. 用 CGPath 畫圖
假設換一種實現方式,不緩存圖片,而是保存每一步,這樣無疑會使內存使用量降低很多,取而代之的是在每次畫圖的時候需要有一個循環來重新畫每一步(可以嘗試用?`clearsContextBeforeDrawing`?屬性來優化),我個人覺得這種方式可能會比較惡心,因為畫的越多,性能就越差,我在前一篇里說過【為什么不用drawRect方法】:
> 為什么不用drawRect方法
>
> 其實我最開始也是使用drawRect方法來完成繪制,但是感覺限制很多,比如context無法保存,還是要每次重畫(雖然可以保存到一個BitMapContext里,但是這樣與保存到image里有什么區別呢?);后來用CALayer保存每一條CGPath,但是這樣仍然不能避免每次重繪,因為需要考慮到橡皮擦和畫筆屬性之類的影響,這么一來還不如采用image的方式來保存最新繪圖板。?
> 既然定下了以image來保存繪圖板,那么drawRect就不方便了,因為不能用UIGraphicsBeginImageContext方法來創建一個ImageContext。
如果決定要用?`CGPath`?來畫圖的話,你除了要暴露一個`CGPath`和`CGContext`以外,你還需要用一個自定義的對象保存當前的繪圖狀態,如`畫筆顏色`、`畫筆粗細`、`混合模式(Blend Mode)`等(還會在后期遇到由于前期考慮不足的屬性沒有設置,然后才加上,這就破壞了“封閉-開放原則”),然后在每一個循環體中`恢復`當前的上下文,類似于這樣:
~~~
CGContextSaveGState...
for path in paths {
CGContextSetLineCap(context, kCGLineCapRound)
CGContextSetLineWidth(context, self.strokeWidth)
CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor)
/* Add path and drawing... */
CGContextRestoreGState...
}
~~~
從代碼上來說,想換成用`CGPath`實現也很容易,只需要改兩個地方:
1. `PaintBrush`?協議,這個協議更新后,其所有的子類同步更新下即可
2. `Board`?的`drawingImage`方法實現
我在 GitHub 里 DrawingBoard 工程里提交了這個分支:?
[DrawingBoard CGPath 分支](https://github.com/zhangao0086/DrawingBoard/tree/Feature/CGPath)?
協議和`drawingImage`進行了適當的更新,繪圖是以`CGPath`來實現的,但是依然采用的是圖片棧的方式,感興趣的同學可以嘗試自己實現。
* * *
### 2\. 優化圖片所占用的內存
除了用`CGPath`來優化以外,我們還可以直接優化圖片棧,用一個緩存或Undo控制器來控制所有的一切,在這個控制器里,將直接管理圖片緩存(內存和文件)、Undo、Redo操作,使 Board 的邏輯進一步的封裝。?
不得不說,這才是我想要實現的方式,模塊之間可以達到真正的解耦,我將?`Board`的代碼去掉沒有改動的方法和屬性后貼在這里:
~~~
class Board: UIImageView {
// UndoManager,用于實現 Undo 操作和維護圖片棧的內存
private class DBUndoManager {
class DBImageFault: UIImage {} // 一個 Fault 對象,與 Core Data 中的 Fault 設計類似
private static let INVALID_INDEX = -1
private var images = [UIImage]() // 圖片棧
private var index = INVALID_INDEX // 一個指針,指向 images 中的某一張圖
var canUndo: Bool {
get {
return index != DBUndoManager.INVALID_INDEX
}
}
var canRedo: Bool {
get {
return index + 1 < images.count
}
}
func addImage(image: UIImage) {
// 當往這個 Manager 中增加圖片的時候,先把指針后面的圖片全部清掉,
// 這與我們之前在 drawingImage 方法中對 redoImages 的處理是一樣的
if index < images.count - 1 {
images[index + 1 ... images.count - 1] = []
}
images.append(image)
// 更新 index 的指向
index = images.count - 1
setNeedsCache()
}
func imageForUndo() -> UIImage? {
if self.canUndo {
--index
if self.canUndo == false {
return nil
} else {
setNeedsCache()
return images[index]
}
} else {
return nil
}
}
func imageForRedo() -> UIImage? {
var image: UIImage? = nil
if self.canRedo {
image = images[++index]
}
setNeedsCache()
return image
}
// MARK: - Cache
private static let cahcesLength = 3 // 在內存中保存圖片的張數,以 index 為中心點計算:cahcesLength * 2 + 1
private func setNeedsCache() {
if images.count >= DBUndoManager.cahcesLength {
let location = max(0, index - DBUndoManager.cahcesLength)
let length = min(images.count - 1, index + DBUndoManager.cahcesLength)
for i in location ... length {
autoreleasepool {
var image = images[i]
if i > index - DBUndoManager.cahcesLength && i < index + DBUndoManager.cahcesLength {
setRealImage(image, forIndex: i) // 如果在緩存區域中,則從文件加載
} else {
setFaultImage(image, forIndex: i) // 如果不在緩存區域中,則置成 Fault 對象
}
}
}
}
}
private static var basePath: String = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true).first as! String
private func setFaultImage(image: UIImage, forIndex: Int) {
if !image.isKindOfClass(DBImageFault.self) {
let imagePath = DBUndoManager.basePath.stringByAppendingPathComponent("\(forIndex)")
UIImagePNGRepresentation(image).writeToFile(imagePath, atomically: false)
images[forIndex] = DBImageFault()
}
}
private func setRealImage(image: UIImage, forIndex: Int) {
if image.isKindOfClass(DBImageFault.self) {
let imagePath = DBUndoManager.basePath.stringByAppendingPathComponent("\(forIndex)")
images[forIndex] = UIImage(data: NSData(contentsOfFile: imagePath)!)!
}
}
}
private var boardUndoManager = DBUndoManager() // 緩存或Undo控制器
// MARK: - Public methods
var canUndo: Bool {
get {
return self.boardUndoManager.canUndo
}
}
var canRedo: Bool {
get {
return self.boardUndoManager.canRedo
}
}
// undo 和 redo 的邏輯都有所簡化
func undo() {
if self.canUndo == false {
return
}
self.image = self.boardUndoManager.imageForUndo()
self.realImage = self.image
}
func redo() {
if self.canRedo == false {
return
}
self.image = self.boardUndoManager.imageForRedo()
self.realImage = self.image
}
// MARK: - drawing
private func drawingImage() {
if let brush = self.brush {
// hook
if let drawingStateChangedBlock = self.drawingStateChangedBlock {
drawingStateChangedBlock(state: self.drawingState)
}
UIGraphicsBeginImageContext(self.bounds.size)
let context = UIGraphicsGetCurrentContext()
UIColor.clearColor().setFill()
UIRectFill(self.bounds)
CGContextSetLineCap(context, kCGLineCapRound)
CGContextSetLineWidth(context, self.strokeWidth)
CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor)
if let realImage = self.realImage {
realImage.drawInRect(self.bounds)
}
brush.strokeWidth = self.strokeWidth
brush.drawInContext(context)
CGContextStrokePath(context)
let previewImage = UIGraphicsGetImageFromCurrentImageContext()
if self.drawingState == .Ended || brush.supportedContinuousDrawing() {
self.realImage = previewImage
}
UIGraphicsEndImageContext()
// 用 Ended 事件代替原先的 Began 事件
if self.drawingState == .Ended {
self.boardUndoManager.addImage(self.image!)
}
self.image = previewImage
brush.lastPoint = brush.endPoint
}
}
}
~~~
以磁盤代替了內存,這里有一些關鍵點:
1. `Board`不需要在對?`self.image`?的取值進行邏輯判斷,`DBUndoManager`會在適當的時候返回nil,這無疑簡化了邏輯
2. 不需要維護兩個圖片棧:undoImages、redoImages,`drawingImage`方法不再需要在 Began 事件里做特殊處理,直接將剛畫完的圖“扔到” UndoManager 中即可
3. undo、redo 方便得到了簡化,適時調用`UndoManager`即可
4. 由于在?`UndoManager`中只有一個圖片棧,所以需要一個額外的指針來指向當前的狀態,當前指針的取值(index)對應下圖中的 i,兩邊的箭頭分別是 undo、redo 對應的圖以及索引:?

5. Fault 對象是一種不錯的設計模式,拿來做占位符挺合適的
6. `UndoManager`會在三種情況下:addImage、undo、redo 對圖片棧進行維護,使 images 里只有 index 兩邊的元素才真正加載 image到內存中,其他的元素用 Fault 對象代替
7. 我為什么要用一個 cahcesLength 變量?這里其實還有進一步優化的余地,如只在讀取到 Fault 對象時才更新圖片棧
那么效果如何呢?我在 4s、Plus 都有進行測試,由于 4s 性能相對較差,我以 4s 為主要測試對象,在內存較少的 4s 上:

在反復繪圖的情況下,內存也是毫無壓力的~!那么讀寫文件的時候是否會有卡頓呢?在 4s 上我發現遠未達到瓶頸:

(PS:4s 的閃存是C10級別)?
cahcesLength 變量配合 index 可以進一步優化性能,在這里就不多做介紹了。
至此,DrawingBoard 就可以告一段落了。
* * *
# [GitHub](https://github.com/zhangao0086/DrawingBoard)
- 前言
- iOS 自定義頁面的切換動畫與交互動畫 By Swift
- Swift 元組(Tuples)介紹
- Swift 可選值(Optional Values)介紹
- Swift Switch介紹
- Swift 值類型和引用類型
- Swift 柯里化(Currying)
- iOS GCD使用指南
- iOS8 Core Image In Swift:自動改善圖像以及內置濾鏡的使用
- 讓Xcode自動更新Build版本
- Swift 全功能的繪圖板開發
- Swift Nullability and Objective-C
- Swift Core Data 圖片存儲與讀取Demo
- Swift 繪圖板功能完善以及終極優化
- 如何設計一個 iOS 控件?(iOS 控件完全解析)