轉載請注明出處:http://blog.csdn.net/zhangao0086/article/details/45289475。
接著這篇:Swift 全功能的繪圖板開發,雖然在上1篇中我們已完成了這些功能:
但是還有1個非常重要的功能沒有實現,沒錯,那就是 Undo/Redo!我之所以把這個功能單獨放出來是有緣由的,1是由于上1篇已篇幅太長,不合適繼續往上加內容;2是由于為了實現 Undo/Redo 功能,我們需要對 DrawingBoard 進行1些重構,在這篇文章中,你能看到用另外一種方式實現的繪圖板。
實現的效果:
先添加兩張按鈕圖:
黑底、50%的透明度,箭頭用白色。
(PS:這可是我自己做的,別厭棄)
圖片放到 Images.xcasserts 里:
(再次PS:圖嫌小的話,就放在2x上)
然后在 Storyboard 里添加兩個 Button:
注意里面的紅框,Button 與 Board 平級,并且在 Board 的上方。
Button 的束縛以下:
兩個按鈕的點擊事件連接到 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()
}
}
...
Undo/Redo 真實的邏輯都在Board
里面,我打算用圖片棧保存 DrawingBoard 的每張圖,當 Undo/Redo 的時候直接把前1個狀態取出并顯示,為了分別存儲 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 類型的,賦1個 nil 給它沒有問題,就當是 undo 到初始化狀態了,但是恰恰 Swift
的數組不支持插入Optional
類型,這就致使我們不能記住 UIImageView 的初始化狀態,只能通過判斷它的image
是不是為 nil 來處理。
完成的邏輯很簡單:當畫圖開始的時候,保存當前 image 到 undo 棧中,并清空 redo 棧,進行 undo 操作的時候,能1直 undo,并將 undo 的 image 存進 redo 棧中,直到 self.image 為 nil。從這個邏輯可以看出兩點:redo 功能非常依賴 undo,畢竟沒有撤銷就沒有重做;除此以外,當用戶開始繪制新圖的時候,我們也要清空 redo 棧,由于用戶已“回不去”了。
完成這些工作后,就可以測試 Undo/Redo 功能了~
我們很快地就加上了 Undo/Redo 功能,是吧? 通過保護兩個圖片棧,在進行相應的操作的時候,直接對 self.image 進行賦值,但是這么做有1個很明顯的弊端,就是內存使用毫無尚限! 你可以很輕松地在 5s 上使內存使用到達 50M 乃至 100M,雖然我們做了1些處理,如當用戶繪制新圖時,清空 Redo 的圖片棧,但是這其實不能從根本上解決問題。
要從根本上解決問題有兩種方式。
假定換1種實現方式,不緩存圖片,而是保存每步,這樣無疑會使內存使用量下降很多,取而代之的是在每次畫圖的時候需要有1個循環來重新畫每步(可以嘗試用 clearsContextBeforeDrawing
屬性來優化),我個人覺得這類方式可能會比較惡心,由于畫的越多,性能就越差,我在前1篇里說過【為何不用drawRect方法】:
為何不用drawRect方法
其實我最開始也是使用drawRect方法來完成繪制,但是感覺限制很多,比如context沒法保存,還是要每次重畫(雖然可以保存到1個BitMapContext里,但是這樣與保存到image里有甚么區分呢?);后來用CALayer保存每條CGPath,但是這樣依然不能避免每次重繪,由于需要斟酌到橡皮擦和畫筆屬性之類的影響,這么1來還不如采取image的方式來保存最新繪圖板。
既然定下了以image來保存繪圖板,那末drawRect就不方便了,由于不能用UIGraphicsBeginImageContext方法來創建1個ImageContext。
如果決定要用 CGPath
來畫圖的話,你除要暴露1個CGPath
和CGContext
之外,你還需要用1個自定義的對象保存當前的繪圖狀態,如畫筆色彩
、畫筆粗細
、混合模式(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
實現也很容易,只需要改兩個地方:
PaintBrush
協議,這個協議更新后,其所有的子類同步更新下便可Board
的drawingImage
方法實現我在 GitHub 里 DrawingBoard 工程里提交了這個分支:
DrawingBoard CGPath 分支
協議和drawingImage
進行了適當的更新,繪圖是以CGPath
來實現的,但是仍然采取的是圖片棧的方式,感興趣的同學可以嘗試自己實現。
除用CGPath
來優化之外,我們還可以直接優化圖片棧,用1個緩存或Undo控制器來控制所有的1切,在這個控制器里,將直接收理圖片緩存(內存和文件)、Undo、Redo操作,使 Board 的邏輯進1步的封裝。
不能不說,這才是我想要實現的方式,模塊之間可以到達真實的解耦,我將 Board
的代碼去掉沒有改動的方法和屬性后貼在這里:
class Board: UIImageView {
// UndoManager,用于實現 Undo 操作和保護圖片棧的內存
private class DBUndoManager {
class DBImageFault: UIImage {} // 1個 Fault 對象,與 Core Data 中的 Fault 設計類似
private static let INVALID_INDEX = -1
private var images = [UIImage]() // 圖片棧
private var index = INVALID_INDEX // 1個指針,指向 images 中的某1張圖
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 的處理是1樣的
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,這無疑簡化了邏輯drawingImage
方法不再需要在 Began 事件里做特殊處理,直接將剛畫完的圖“扔到” UndoManager 中便可UndoManager
便可UndoManager
中只有1個圖片棧,所以需要1個額外的指針來指向當前的狀態,當前指針的取值(index)對應下圖中的 i,兩邊的箭頭分別是 undo、redo 對應的圖和索引: UndoManager
會在3種情況下:addImage、undo、redo 對圖片棧進行保護,使 images 里只有 index 兩邊的元素才真正加載 image到內存中,其他的元素用 Fault 對象代替那末效果如何呢?我在 4s、Plus 都有進行測試,由于 4s 性能相對較差,我以 4s 為主要測試對象,在內存較少的 4s 上:
在反復繪圖的情況下,內存也是毫無壓力的~!那末讀寫文件的時候是不是會有卡頓呢?在 4s 上我發現遠未到達瓶頸:
(PS:4s 的閃存是C10級別)
cahcesLength 變量配合 index 可以進1步優化性能,在這里就不多做介紹了。
至此,DrawingBoard 就能夠告1段落了。