- 原文地址:Intermediate Design Patterns in Swift
- 原文作者:raywenderlich.com
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:iWeslie
- 校對者:swants, kirinzer
設計模式對於程式碼的維護和提高可讀性非常有用,通過本教程你將學習 Swift 中的一些設計模式。
更新說明:本教程已由譯者針對 iOS 12,Xcode 10 和 Swift 4.2 進行了更新。
新手教程:沒了解過設計模式?來看看設計模式的 入門教程 來閱讀之前的基礎知識吧。
在本教程中,你將學習如何使用 Swift 中的設計模式來重構一個名為 Tap the Larger Shape 的遊戲。
瞭解設計模式對於編寫可維護且無 bug 的應用程式至關重要,瞭解何時採用何種設計模式是一項只能通過實踐學習的技能。這本教程再好不過了!
但究竟什麼是設計模式呢?這是一個針對常見問題的正式文件型解決方案。例如,考慮一下遍歷一個集合,你在此處使用 迭代器 設計模式:
var collection = ...
// for 迴圈使用迭代器設計模式
for item in collection {
print("Item is: \(item)")
}
複製程式碼
迭代器 設計模式的價值在於它抽象出了訪問集合中每一項的實際底層機制。無論 collection
是陣列,字典還是其他型別,你的程式碼都可以用相同的方式訪問它們中的每一項。
不僅如此,設計模式也是開發者文化的一部分,因此維護或擴充套件程式碼的另一個開發人員可能會理解迭代器設計模式,它們是用於推理出軟體架構的語言。
在 iOS 程式設計中有很多設計模式頻繁出現,例如 MVC 出現在幾乎每個應用程式中,代理 是一個強大的,通常未被充分利用的模式,比如說你曾用過的 tableView,本教程討論了一些鮮為人知但非常有用的設計模式。
如果你不熟悉設計模式的概念,此篇文章可能不適合現在的你,不妨先看一下 使用 Swift 的 iOS 設計模式 來開始吧。
入門
Tap the Larger Shape 是一個有趣但簡單的遊戲,你會看到一對相似的形狀,你需要點選兩者中較大的一個。如果你點選較大的形狀,你會得到一分,反之你會失去一分。
看起來你好像只噴出了一些隨機的方塊、圓圈和三角形塗鴉,不過孩子們會買單的!:]
下載 入門專案 並在 Xcode 中開啟。
注意:你需要使用 Xcode 10 和 Swift 4.2 及以上版本從而獲得最大的相容性和穩定性。
此入門專案包含完整遊戲,你將在本教程中對改專案進行重構並利用一些設計模式來使你的遊戲更易於維護並且更加有趣。
使用 iPhone 8 模擬器,編譯並執行專案,隨意點選幾個圖形來了解這個遊戲的規則。你會看到如下圖所示的內容:
點選較大的圖形就能得分。
點選較小的圖形則會扣分。
理解這款遊戲
在深入瞭解設計模式的細節之前,先看一下目前編寫的遊戲。開啟 Shape.swift 看一看並找到以下程式碼,你無需進行任何更改,只需要看看就行:
import UIKit
class Shape {
}
class SquareShape: Shape {
var sideLength: CGFloat!
}
複製程式碼
Shape
類是遊戲中可點選圖形的基本模型。具體的一個子類 SquareShape
表示一個正方形:一個具有四條等長邊的多邊形。
接下來開啟 ShapeView.swift 並檢視 ShapeView
的程式碼:
import UIKit
class ShapeView: UIView {
var shape: Shape!
// 1
var showFill: Bool = true {
didSet {
setNeedsDisplay()
}
}
var fillColor: UIColor = UIColor.orange {
didSet {
setNeedsDisplay()
}
}
// 2
var showOutline: Bool = true {
didSet {
setNeedsDisplay()
}
}
var outlineColor: UIColor = UIColor.gray {
didSet {
setNeedsDisplay()
}
}
// 3
var tapHandler: ((ShapeView) -> ())?
override init(frame: CGRect) {
super.init(frame: frame)
// 4
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapRecognizer)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func handleTap() {
// 5
tapHandler?(self)
}
let halfLineWidth: CGFloat = 3.0
}
複製程式碼
ShapeView
是呈現通用 Shape
模型的 view。以下是其中程式碼的逐行解析:
-
指明應用程式是否使用,並使用哪種顏色來填充圖形,這是圖形內部的顏色。
-
指明應用程式是否使用,並使用哪種顏色來給圖形描邊,這是圖形邊框的顏色。
-
一個處理點選事件的閉包(例如更新得分)。如果你不熟悉 Swift 閉包,可以在 Swift 閉包 中檢視它們,但請記住它們與 Objective-C 裡的 block 類似。
-
設定一個 tap gesture recognizer,當玩家點選 view 時呼叫
handleTap
。 -
當檢測到點選手勢時呼叫
tapHandler
。
現在向下滾動並且檢視 SquareShapeView
:
class SquareShapeView: ShapeView {
override func draw(_ rect: CGRect) {
super.draw(rect)
// 1
if showFill {
fillColor.setFill()
let fillPath = UIBezierPath(rect: bounds)
fillPath.fill()
}
// 2
if showOutline {
outlineColor.setStroke()
// 3
let outlinePath = UIBezierPath(rect: CGRect(x: halfLineWidth, y: halfLineWidth, width: bounds.size.width - 2 * halfLineWidth, height: bounds.size.height - 2 * halfLineWidth))
outlinePath.lineWidth = 2.0 * halfLineWidth
outlinePath.stroke()
}
}
}
複製程式碼
以下是 SquareShapeView
如何進行繪製的:
-
如果配置為顯示填充,則使用填充顏色填充 view。
-
如果配置為顯示輪廓,則使用輪廓顏色給 view 描邊。
-
由於 iOS 是以 position 為中心繪製線條的,因此我們在描邊路徑時需要將從 view 的 bounds 裡減去
halfLineWidth
。
很棒!現在你已經瞭解了這個遊戲裡的圖形是如繪製的,開啟 GameViewController.swift 並檢視其中的邏輯:
import UIKit
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 1
beginNextTurn()
}
override var prefersStatusBarHidden: Bool {
return true
}
private func beginNextTurn() {
// 2
let shape1 = SquareShape()
shape1.sideLength = Utils.randomBetweenLower(lower: 0.3, andUpper: 0.8)
let shape2 = SquareShape()
shape2.sideLength = Utils.randomBetweenLower(lower: 0.3, andUpper: 0.8)
// 3
let availSize = gameView.sizeAvailableForShapes()
// 4
let shapeView1: ShapeView = SquareShapeView(frame: CGRect(x: 0, y: 0, width: availSize.width * shape1.sideLength, height: availSize.height * shape1.sideLength))
shapeView1.shape = shape1
let shapeView2: ShapeView = SquareShapeView(frame: CGRect(x: 0, y: 0, width: availSize.width * shape2.sideLength, height: availSize.height * shape2.sideLength))
shapeView2.shape = shape2
// 5
let shapeViews = (shapeView1, shapeView2)
// 6
shapeViews.0.tapHandler = { tappedView in
self.gameView.score += shape1.sideLength >= shape2.sideLength ? 1 : -1
self.beginNextTurn()
}
shapeViews.1.tapHandler = { tappedView in
self.gameView.score += shape2.sideLength >= shape1.sideLength ? 1 : -1
self.beginNextTurn()
}
// 7
gameView.addShapeViews(newShapeViews: shapeViews)
}
private var gameView: GameView { return view as! GameView }
}
複製程式碼
以下是遊戲邏輯的工作原理:
-
當
GameView
載入後開始新的一局。 -
在
[0.3, 0.8]
區間內取邊長繪製正方形,繪製的圖形也可以在任何螢幕尺寸下縮放。 -
由
GameView
確定哪種尺寸的圖形適合當前螢幕。 -
為每個形狀建立一個
SquareShapeView
,並通過將圖形的sideLength
比例乘以當前螢幕的相應availSize
來調整形狀的大小。 -
將形狀儲存在元組中以便於操作。
-
在每個 shape view 上設定點選事件並根據玩家是否點選較大的 view 來計算分數。
-
將形狀新增到
GameView
以便佈局顯示。
以上就是遊戲的完整邏輯。是不是很簡單?:]
為什麼要使用設計模式?
你可能想問自己:“嗯,所以當我有一個工作遊戲時,為什麼我需要設計模式呢?”那麼如果你想支援除了正方形以外的形狀又要怎麼辦呢?
你 本可以 在 beginNextTurn
中新增程式碼來建立第二個形狀,但是當你新增第三種、第四種甚至第五種形狀時,程式碼將變得難以管理。
如果你希望玩家能夠選擇別人的形狀又要怎麼辦呢?
如果你把所有程式碼放在 GameViewController
中,你最終會得到難以管理的包含硬編碼依賴的耦合度很高的程式碼。
以下是你的問題的答案:設計模式有助於將你的程式碼解耦成分離地很開的單位。
在進行下一步之前,我坦白,我已經偷偷地進入了一個設計模式。
現在,關於設計模式,以下的每個部分都描述了不同的設計模式。我們開始吧!
抽象工廠模式
GameViewController
與 SquareShapeView
緊密耦合,這將不能為以後使用不同的檢視來表示正方形或引入第二個形狀留出餘地。
你的第一個任務是使用 抽象工廠 設計模式給你的GameViewController
進行簡化和解耦。你將要在程式碼中使用此模式,該程式碼建立用於構造一組相關物件的API,例如你將暫時使用的 shape view,而無需對特定類進行硬編碼。
新建一個 Swift 檔案,命名為 ShapeViewFactory.swift 並儲存,然後新增以下程式碼:
import UIKit
// 1
protocol ShapeViewFactory {
// 2
var size: CGSize { get set }
// 3
func makeShapeViewsForShapes(shapes: (Shape, Shape)) -> (ShapeView, ShapeView)
}
複製程式碼
以下是你新的工廠的工作原理:
-
將
ShapeViewFactory
定義為 Swift 協議,它沒有理由成為一個類或結構體,因為它只描述了一個介面而本身並沒有功能。 -
每個工廠應當有一個定義了建立形狀的邊界的尺寸,這對使用工廠生成的 view 佈局程式碼至關重要。
-
定義生成形狀檢視的方法。這是工廠的“肉”,它需要兩個 Shape 物件的元組,並返回兩個 ShapeView 物件的元組。這基本上是從其原材料 — 模型中製造 view。
在 ShapeViewFactory.swift 的最後新增以下程式碼:
class SquareShapeViewFactory: ShapeViewFactory {
var size: CGSize
// 1
init(size: CGSize) {
self.size = size
}
func makeShapeViewsForShapes(shapes: (Shape, Shape)) -> (ShapeView, ShapeView) {
// 2
let squareShape1 = shapes.0 as! SquareShape
let shapeView1 = SquareShapeView(frame: CGRect(
x: 0,
y: 0,
width: squareShape1.sideLength * size.width,
height: squareShape1.sideLength * size.height))
shapeView1.shape = squareShape1
// 3
let squareShape2 = shapes.1 as! SquareShape
let shapeView2 = SquareShapeView(frame: CGRect(
x: 0,
y: 0,
width: squareShape2.sideLength * size.width,
height: squareShape2.sideLength * size.height))
shapeView2.shape = squareShape2
// 4
return (shapeView1, shapeView2)
}
}
複製程式碼
你的 SquareShapeViewFactory
建造了 SquareShapeView
例項,如下所示:
-
使用一致的最大尺寸來初始化工廠。
-
從第一個傳遞的形狀構造第一個 shape view。
-
從第二個傳遞的形狀構造第二個 shape view。
-
返回包含兩個剛建立的 shape view 的元組。
最後,是時候使用 SquareShapeViewFactory
了。開啟 GameViewController.swift,並全部替換為以下內容:
import UIKit
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 1 ***** 附加
shapeViewFactory = SquareShapeViewFactory(size: gameView.sizeAvailableForShapes())
beginNextTurn()
}
override var prefersStatusBarHidden: Bool {
return true
}
private func beginNextTurn() {
let shape1 = SquareShape()
shape1.sideLength = Utils.randomBetweenLower(lower: 0.3, andUpper: 0.8)
let shape2 = SquareShape()
shape2.sideLength = Utils.randomBetweenLower(lower: 0.3, andUpper: 0.8)
// 2 ***** 附加
let shapeViews = shapeViewFactory.makeShapeViewsForShapes(shapes: (shape1, shape2))
shapeViews.0.tapHandler = { tappedView in
self.gameView.score += shape1.sideLength >= shape2.sideLength ? 1 : -1
self.beginNextTurn()
}
shapeViews.1.tapHandler = { tappedView in
self.gameView.score += shape2.sideLength >= shape1.sideLength ? 1 : -1
self.beginNextTurn()
}
gameView.addShapeViews(newShapeViews: shapeViews)
}
private var gameView: GameView { return view as! GameView }
// 3 ***** 附加
private var shapeViewFactory: ShapeViewFactory!
}
複製程式碼
這裡有三行新程式碼:
-
初始化並儲存一個
SquareShapeViewFactory
。 -
使用此新工廠建立你的 shape view。
-
將新的 shape view 工廠儲存為例項屬性。
主要的好處在於第二部分,其中你用一行替換了六行程式碼。更好的是,你將複雜的 shape view 的建立程式碼移出了 GameViewController
從而使類更小也更容易理解。
將 view 建立程式碼移出 controller 是很有幫助的,因為 GameViewController
充當 Controller 在 Model 和 View 之間進行協調。
編譯並執行,然後你應該看到類似以下內容:
你遊戲的視覺效果沒有任何改變,但你確實簡化了程式碼。
如果你用 SomeOtherShapeView
替換 SquareShapeView
,那麼 SquareShapeViewFactory
的好處就會大放異彩。具體來說,你不需要更改 GameViewController
,你可以將所有更改分離到 SquareShapeViewFactory
。
既然你已經簡化了 shape view 的建立,那麼你也同時可以簡化 shape 的建立。像之前那樣建立一個新的 Swift 檔案,命名為 ShapeFactory.swift,並把以下程式碼貼上進去:
import UIKit
// 1
protocol ShapeFactory {
func createShapes() -> (Shape, Shape)
}
class SquareShapeFactory: ShapeFactory {
// 2
var minProportion: CGFloat
var maxProportion: CGFloat
init(minProportion: CGFloat, maxProportion: CGFloat) {
self.minProportion = minProportion
self.maxProportion = maxProportion
}
func createShapes() -> (Shape, Shape) {
// 3
let shape1 = SquareShape()
shape1.sideLength = Utils.randomBetweenLower(lower: minProportion, andUpper: maxProportion)
// 4
let shape2 = SquareShape()
shape2.sideLength = Utils.randomBetweenLower(lower: minProportion, andUpper: maxProportion)
// 5
return (shape1, shape2)
}
}
複製程式碼
你的新 ShapeFactory
生產 shape 的具體步驟如下:
-
再一次地,就像你對
ShapeViewFactory
所做的那樣,將ShapeFactory
宣告為一個協議來獲得最大的靈活性。 -
你希望你的 shape 工廠生成具有單位尺寸的形狀,例如,在
[0, 1]
的範圍內,因此你要儲存這個範圍。 -
建立具有隨機尺寸的第一個方形。
-
建立具有隨機尺寸的第二個方形。
-
將這對方形形狀作為元組返回。
現在開啟 GameViewController.swift 並在底部大括號結束之前的插入以下程式碼:
private var shapeFactory: ShapeFactory!
複製程式碼
然後在 viewDidLoad
的底部 beginNextTurn
的呼叫之上插入以下程式碼:
shapeFactory = SquareShapeFactory(minProportion: 0.3, maxProportion: 0.8)
複製程式碼
最後把 beginNextTurn
替換為以下程式碼:
private func beginNextTurn() {
// 1
let shapes = shapeFactory.createShapes()
let shapeViews = shapeViewFactory.makeShapeViewsForShapes(shapes: shapes)
shapeViews.0.tapHandler = { tappedView in
// 2
let square1 = shapes.0 as! SquareShape, square2 = shapes.1 as! SquareShape
// 3
self.gameView.score += square1.sideLength >= square2.sideLength ? 1 : -1
self.beginNextTurn()
}
shapeViews.1.tapHandler = { tappedView in
let square1 = shapes.0 as! SquareShape, square2 = shapes.1 as! SquareShape
self.gameView.score += square2.sideLength >= square1.sideLength ? 1 : -1
self.beginNextTurn()
}
gameView.addShapeViews(newShapeViews: shapeViews)
}
複製程式碼
以下是上面程式碼的解析:
-
使用新的 shape 工廠建立一個形狀元組。
-
從元組中提取形狀。
-
這樣你就可以在這裡比較它們了。
再一次使用 抽象工廠 設計模式,通過將建立形狀的部分移出 GameViewController
來簡化程式碼。
僱工模式
現在你甚至可以新增第二個形狀,例如圓圈。你對正方形的唯一硬性依賴是下面 beginNextTurn
中的得分計算:
shapeViews.1.tapHandler = { tappedView in
// 1
let square1 = shapes.0 as! SquareShape, square2 = shapes.1 as! SquareShape
// 2
self.gameView.score += square2.sideLength >= square1.sideLength ? 1 : -1
self.beginNextTurn()
}
複製程式碼
在這裡你把形狀轉換為 SquareShape
以便你可以訪問它們的 sideLength
,圓沒有 sideLength
,而是“直徑”。
解決方案是使用 僱工 設計模式,它通過一個通用介面為一組類(如形狀類)提供分數計算等方法。在你現在的情況下,分數計算是僱工,形狀類作為服務物件,並且 area
屬性扮演公共介面的角色。
開啟 Shape.swift 並在 Shape
類的底部新增以下程式碼:
var area: CGFloat { return 0 }
複製程式碼
然後在 SquareShape
類的底部新增以下程式碼:
override var area: CGFloat { return sideLength * sideLength }
複製程式碼
現在你可以根據其面積來判斷哪個形狀更大。
開啟 GameViewController.swift 並把 beginNextTurn
替換成以下內容:
private func beginNextTurn() {
let shapes = shapeFactory.createShapes()
let shapeViews = shapeViewFactory.makeShapeViewsForShapes(shapes: shapes)
shapeViews.0.tapHandler = { tappedView in
// 1
self.gameView.score += shapes.0.area >= shapes.1.area ? 1 : -1
self.beginNextTurn()
}
shapeViews.1.tapHandler = { tappedView in
// 2
self.gameView.score += shapes.1.area >= shapes.0.area ? 1 : -1
self.beginNextTurn()
}
gameView.addShapeViews(newShapeViews: shapeViews)
}
複製程式碼
-
根據形狀區域確定較大的形狀。
-
還是根據形狀區域確定較大的形狀。
編譯並執行,你應該看到類似下面的內容,雖然遊戲看起來相同,但程式碼現在更靈活了。
恭喜,你已經從遊戲邏輯中完全解除了對正方形的依賴關係,如果你要建立和使用一些圓形的工廠,你的遊戲將變得更加完善。
利用抽象工廠實現遊戲的多功能性
“不要做一個古板的人!”在現實生活中可能是一種侮辱,你的遊戲感覺它被裝在一個形狀中,它渴望更流暢的線條和更多的符合空氣動力學的形狀。
你需要引入一些流暢的“善良的圓”,現在開啟 Shape.swift 並在檔案底部新增以下程式碼:
class CircleShape: Shape {
var diameter: CGFloat!
override var area: CGFloat { return CGFloat.pi * diameter * diameter / 4.0 }
}
複製程式碼
你的圓只需要知道它可以計算自身面積的“直徑”就可以支援 僱工 模式。
接下來通過新增 CircleShapeFactory
來構建 CircleShape
物件。開啟 ShapeFactory.swift 並在檔案底部新增以下程式碼:
class CircleShapeFactory: ShapeFactory {
var minProportion: CGFloat
var maxProportion: CGFloat
init(minProportion: CGFloat, maxProportion: CGFloat) {
self.minProportion = minProportion
self.maxProportion = maxProportion
}
func createShapes() -> (Shape, Shape) {
// 1
let shape1 = CircleShape()
shape1.diameter = Utils.randomBetweenLower(lower: minProportion, andUpper: maxProportion)
// 2
let shape2 = CircleShape()
shape2.diameter = Utils.randomBetweenLower(lower: minProportion, andUpper: maxProportion)
return (shape1, shape2)
}
}
複製程式碼
這段程式碼遵循一個熟悉的模式:第1部分 和 第2部分 建立了一個 CircleShape
併為其指定一個隨機的 diameter
。
你需要解決另一個問題,這樣做可能會防止一個混亂的幾何圖形的革命。看吧,你現在擁有的是 “沒有代表性的幾何圖形”,你知道當形狀不足時,形狀會變得多麼乾淨哈!
取悅你的玩家很容易,你需要的只是用 CircleShapeView
在螢幕上 繪製 你的新 CircleShape
物件。:]
開啟 ShapeView.swift
並在檔案底部新增以下內容:
class CircleShapeView: ShapeView {
override init(frame: CGRect) {
super.init(frame: frame)
// 1
self.isOpaque = false
// 2
self.contentMode = UIView.ContentMode.redraw
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
if showFill {
fillColor.setFill()
// 3
let fillPath = UIBezierPath(ovalIn: self.bounds)
fillPath.fill()
}
if showOutline {
outlineColor.setStroke()
// 4
let outlinePath = UIBezierPath(ovalIn: CGRect(
x: halfLineWidth,
y: halfLineWidth,
width: self.bounds.size.width - 2 * halfLineWidth,
height: self.bounds.size.height - 2 * halfLineWidth))
outlinePath.lineWidth = 2.0 * halfLineWidth
outlinePath.stroke()
}
}
}
複製程式碼
對上述內容的解釋依次為以下幾個部分:
-
由於圓無法填充其 view 的 bounds,因此你需要告訴 UIKit 該 view 是透明的,這意味著能透過它看到背後的東西。如果你沒有意識到這點,那麼這個圓將會有一個醜陋的黑色背景。
-
由於檢視是透明的,因此應在 bounds 更改時進行重繪。
-
畫一個用
fillColor
填充的圓圈。稍後,你將建立CircleShapeViewFactory
,它會確保CircleView
具有相等的寬度和高度,因此畫出來的形狀將是圓形而不是橢圓形。 -
給圓用 lineWidth 進行描邊。
現在你將在 CircleShapeViewFactory
中建立CircleShapeView
物件。
開啟 ShapeViewFactory.swift 並在檔案的底部新增以下程式碼:
class CircleShapeViewFactory: ShapeViewFactory {
var size: CGSize
init(size: CGSize) {
self.size = size
}
func makeShapeViewsForShapes(shapes: (Shape, Shape)) -> (ShapeView, ShapeView) {
let circleShape1 = shapes.0 as! CircleShape
// 1
let shapeView1 = CircleShapeView(frame: CGRect(
x: 0,
y: 0,
width: circleShape1.diameter * size.width,
height: circleShape1.diameter * size.height))
shapeView1.shape = circleShape1
let circleShape2 = shapes.1 as! CircleShape
// 2
let shapeView2 = CircleShapeView(frame: CGRect(
x: 0,
y: 0,
width: circleShape2.diameter * size.width,
height: circleShape2.diameter * size.height))
shapeView2.shape = circleShape2
return (shapeView1, shapeView2)
}
}
複製程式碼
這是將建立圓而不是正方形的工廠。第1部分 和 第2部分 使用傳入的形狀建立 CircleShapeView
例項。請注意你的程式碼是如何確保圓圈具有相同的寬度和高度,因此它們呈現為完美的圓形而不是橢圓形。
最後,開啟 GameViewController.swift 並替換 viewDidLoad
中對應的兩行,用以下內容分配形狀和檢視工廠:
shapeViewFactory = CircleShapeViewFactory(size: gameView.sizeAvailableForShapes())
shapeFactory = CircleShapeFactory(minProportion: 0.3, maxProportion: 0.8)
複製程式碼
現在編譯並執行專案,你應該看到類似下面的截圖。
瞧,你造出了圓形!請注意你是如何在 GameViewController
中新增新形狀而不會對遊戲邏輯產生太大影響的,抽象工廠和僱工設計模式使之成為可能。
建造者模式
現在是時候來看看第三種設計模式了:建造者。
假設你想要改變 ShapeView
例項的外觀 - 例如它們是否應顯示,以及用什麼顏色來填充和描邊。 建造者 設計模式使這種物件的配置變得更加容易和靈活。
解決此配置問題的一種方法是新增各種建構函式,可以使用諸如 CircleShapeView.redFilledCircleWithBlueOutline()
之類的類便利初始化方法,也可以新增具有各種引數和預設值的初始化方法。
然而不幸的是,它不是一種可擴充套件的技術,因為你需要為每種組合編寫新方法或初始化程式。
建造者非常優雅地解決了這個問題,因為它建立了一個具有單一用途的類來配置已經初始化的物件。如果你將讓建造者來構建紅色的圓,然後再構建藍色的圓,則無需更改 CircleShapeView
就可達到目的。
建立一個新檔案 ShapeViewBuilder.swift 並新增以下程式碼:
import UIKit
class ShapeViewBuilder {
// 1
var showFill = true
var fillColor = UIColor.orange
// 2
var showOutline = true
var outlineColor = UIColor.gray
// 3
init(shapeViewFactory: ShapeViewFactory) {
self.shapeViewFactory = shapeViewFactory
}
// 4
func buildShapeViewsForShapes(shapes: (Shape, Shape)) -> (ShapeView, ShapeView) {
let shapeViews = shapeViewFactory.makeShapeViewsForShapes(shapes: shapes)
configureShapeView(shapeView: shapeViews.0)
configureShapeView(shapeView: shapeViews.1)
return shapeViews
}
// 5
private func configureShapeView(shapeView: ShapeView) {
shapeView.showFill = showFill
shapeView.fillColor = fillColor
shapeView.showOutline = showOutline
shapeView.outlineColor = outlineColor
}
private var shapeViewFactory: ShapeViewFactory
}
複製程式碼
以下是你的新的 ShapeViewBuilder
的工作原理:
-
儲存配置
ShapeView
的填充屬性。 -
儲存配置
ShapeView
的描邊屬性。 -
初始化建造者來持有
ShapeViewFactory
從而構造 view。這意味著建造者並不需要知道它是來建造SquareShapeView
還是CircleShapeView
抑或是其他形狀的 view。 -
這是公共 API,當有一對
Shape
時,它會建立並初始化一對ShapeView
。 -
根據建造者的儲存了的配置來對
ShapeView
進行配置。
現在來部署你新的 ShapeViewBuilder
,開啟 GameViewController.swift,在大括號結束之前將以下程式碼新增到類的底部:
private var shapeViewBuilder: ShapeViewBuilder!
複製程式碼
現在在 viewDidLoad
裡 beginNextTurn
呼叫的上方新增以下程式碼來填充新屬性:
shapeViewBuilder = ShapeViewBuilder(shapeViewFactory: shapeViewFactory)
shapeViewBuilder.fillColor = UIColor.brown
shapeViewBuilder.outlineColor = UIColor.orange
複製程式碼
最後用以下程式碼替換 beginNextTurn
中建立 shapeViews
的那一行:
let shapeViews = shapeViewBuilder.buildShapeViewsForShapes(shapes: shapes)
複製程式碼
編譯並執行,你將看到以下內容:
說實話我也覺得填充顏色很醜,但是先別吐槽,畢竟我們目前關注點不在於它是有多麼好看。
現在來強化建造者的力量。還是在 GameViewController.swift
裡,將 viewDidLoad
對應的兩行更改為使用方形工廠:
shapeViewFactory = SquareShapeViewFactory(size: gameView.sizeAvailableForShapes())
shapeFactory = SquareShapeFactory(minProportion: 0.3, maxProportion: 0.8)
複製程式碼
編譯並執行,你將看到以下內容:
注意建造者模式是如何使新的顏色方案來應用到正方形和圓形上的。沒有它的話你需要在 CircleShapeViewFactory
和 SquareShapeViewFactory
中來單獨設定顏色。
此外,更改為另一種配色方案將涉及大量程式碼的修改。通過將 ShapeView
顏色配置限制為單個 ShapeViewBuilder
,你還可以將顏色更改隔離到單個類。
依賴注入模式
每次點選一個形狀,你都會進行一個回合,每回合的結果可以是得分或者減分。
如果你的遊戲可以自動跟蹤所有回合,統計資料和得分,那會不會有幫助呢?
建立一個名為 Turn.swift 的新檔案,並使用以下程式碼替換其內容:
class Turn {
// 1
let shapes: [Shape]
var matched: Bool?
init(shapes: [Shape]) {
self.shapes = shapes
}
// 2
func turnCompletedWithTappedShape(tappedShape: Shape) {
let maxArea = shapes.reduce(0) { $0 > $1.area ? $0 : $1.area }
matched = tappedShape.area >= maxArea
}
}
複製程式碼
你的新 Turn
類做了以下事情:
-
儲存玩家每一回合看到的形狀,以及是否點選了較大的形狀。
-
在玩家點選形狀後記錄該回合已經結束。
要控制玩家玩的回合順序,請建立一個名為 TurnController.swift 的新檔案,並使用以下程式碼替換其內容:
class TurnController {
// 1
var currentTurn: Turn?
var pastTurns: [Turn] = [Turn]()
// 2
init(shapeFactory: ShapeFactory, shapeViewBuilder: ShapeViewBuilder) {
self.shapeFactory = shapeFactory
self.shapeViewBuilder = shapeViewBuilder
}
// 3
func beginNewTurn() -> (ShapeView, ShapeView) {
let shapes = shapeFactory.createShapes()
let shapeViews = shapeViewBuilder.buildShapeViewsForShapes(shapes: shapes)
currentTurn = Turn(shapes: [shapeViews.0.shape, shapeViews.1.shape])
return shapeViews
}
// 4
func endTurnWithTappedShape(tappedShape: Shape) -> Int {
currentTurn!.turnCompletedWithTappedShape(tappedShape: tappedShape)
pastTurns.append(currentTurn!)
let scoreIncrement = currentTurn!.matched! ? 1 : -1
return scoreIncrement
}
private let shapeFactory: ShapeFactory
private var shapeViewBuilder: ShapeViewBuilder
}
複製程式碼
你的 TurnController
工作原理如下:
-
儲存當前和過去的回合。
-
接收一個
ShapeFactory
和一個ShapeViewBuilder
。 -
使用此工廠和建造者為每個新的回合建立形狀和檢視,並記錄當前回合。
-
在玩家點選形狀後記錄回合結束,並根據該回合玩家點選的形狀計算得分。
現在開啟 GameViewController.swift,並在底部大括號上方新增以下程式碼:
private var turnController: TurnController!
複製程式碼
向上滾動到 viewDidLoad
,在呼叫 beginNewTurn
這行之前,插入以下程式碼:
turnController = TurnController(shapeFactory: shapeFactory, shapeViewBuilder: shapeViewBuilder)
複製程式碼
用以下程式碼替換 beginNextTurn
:
private func beginNextTurn() {
// 1
let shapeViews = turnController.beginNewTurn()
shapeViews.0.tapHandler = { tappedView in
// 2
self.gameView.score += self.turnController.endTurnWithTappedShape(tappedShape: tappedView.shape)
self.beginNextTurn()
}
// 3
shapeViews.1.tapHandler = shapeViews.0.tapHandler
gameView.addShapeViews(newShapeViews: shapeViews)
}
複製程式碼
你的新程式碼的工作原理如下:
-
讓
TurnController
開始一個新的回合並返回一個ShapeView
元組用於回合。 -
當玩家點選
ShapeView
時,通知控制器回合結束,然後計算得分。請注意TurnController
是如何把得分計算的過程抽象出來並進一步簡化GameViewController
。 -
由於你移除了對特定形狀的顯式引用,因此第二個形狀檢視可以與第一個形狀檢視共享相同的
tapHandler
閉包。
依賴注入 設計模式的一個例項應用是它將其依賴項傳遞給 TurnController
初始化器,初始化器的引數主要是要注入的形狀和工廠的依賴項。
由於 TurnController
沒有假定使用哪種型別的工廠,因此你可以自由地在不同的工廠間進行交換。
這不僅使你的遊戲更加靈活,還讓自動化測試變得更容易了。如果你想的話,它允許你向特殊的 TestShapeFactory
和 TestShapeViewFactory
類傳遞引數。這些可能是特殊的存根或模擬,可以使測試更容易、更可靠並且更快速。
Build and run and check that it looks like this:編譯並執行,你會看到如下圖:
介面好像沒什麼變化,但是 TurnController
已經開放了你的程式碼,所以它可以使用更復雜的回合機制:根據回合計算得分然後在每一回合之間選擇性的改變形狀,甚至根據玩家的表現調整比賽難度。
策略模式
我現在特別高興因為我在寫這個教程時正在吃一塊派,也許這就是為什麼我們在遊戲中要新增圓形了哈。:]
你應該感到高興,因為你在使用設計模式重構遊戲程式碼方面做得很好,遊戲因此變得很容易擴充套件和維護。
說到派,呃,Pi,你要怎麼把這些圓形放回遊戲中呢?現在你的 GameViewController
可以使用 圓或正方形,但只能使用其中一個。並不一定都要限制的死死的。
接下來你將使用 策略 模式來管理遊戲裡的形狀。
策略 設計模式允許你根據程式在執行時確定的內容來設計演算法。在這種情況下,演算法將選擇向玩家呈現什麼樣的形狀。
你可以設計許多不同的演算法:一種是隨機選擇形狀,一種是挑選形狀來給玩家一點挑戰或者幫助他獲勝更多,等等!策略 通過對每個策略必須實現的行為的抽象宣告來定義一系列演算法,這使得該族內的演算法可以互換。
如果你猜想你將會把策略作為一個 Swift protocol
來實現,那你就猜對了!
Create a new file named TurnStrategy.swift, and replace its contents with the following code:建立一個名為 TurnStrategy.swift 的新檔案,並使用以下程式碼替換其內容:
// 1
protocol TurnStrategy {
func makeShapeViewsForNextTurnGivenPastTurns(pastTurns: [Turn]) -> (ShapeView, ShapeView)
}
// 2
class BasicTurnStrategy: TurnStrategy {
let shapeFactory: ShapeFactory
let shapeViewBuilder: ShapeViewBuilder
init(shapeFactory: ShapeFactory, shapeViewBuilder: ShapeViewBuilder) {
self.shapeFactory = shapeFactory
self.shapeViewBuilder = shapeViewBuilder
}
func makeShapeViewsForNextTurnGivenPastTurns(pastTurns: [Turn]) -> (ShapeView, ShapeView) {
return shapeViewBuilder.buildShapeViewsForShapes(shapes: shapeFactory.createShapes())
}
}
class RandomTurnStrategy: TurnStrategy {
// 3
let firstStrategy: TurnStrategy
let secondStrategy: TurnStrategy
init(firstStrategy: TurnStrategy, secondStrategy: TurnStrategy) {
self.firstStrategy = firstStrategy
self.secondStrategy = secondStrategy
}
// 4
func makeShapeViewsForNextTurnGivenPastTurns(pastTurns: [Turn]) -> (ShapeView, ShapeView) {
if Utils.randomBetweenLower(lower: 0.0, andUpper: 100.0) < 50.0 {
return firstStrategy.makeShapeViewsForNextTurnGivenPastTurns(pastTurns: pastTurns)
} else {
return secondStrategy.makeShapeViewsForNextTurnGivenPastTurns(pastTurns: pastTurns)
}
}
}
複製程式碼
以下是你的新的 TurnStrategy
進行的操作:
-
這是在一個協議中定義的一個抽象方法,該方法獲取遊戲中上一個回合的陣列,並返回形狀檢視來顯示下一回合。
-
實現一個使用
ShapeFactory
和ShapeViewBuilder
的基本策略,此策略實現了現有行為,其中形狀檢視與以前一樣來自單個工廠和建造者。請注意你在此處再次使用 依賴注入,這意味著此策略不關心它使用的是哪一個工廠或建造者。 -
隨機使用其他兩種策略之一來實施隨機策略。你在這裡使用了組合,因此
RandomTurnStrategy
可以表現得像兩個可能不同的策略。但是由於它是一個策略
,所以任何使用RandomTurnStrategy
的程式碼都隱藏了該組合。 -
這是隨機策略的核心。它以 50% 的概率隨機選擇第一種或第二種策略。
現在你需要使用你的策略。開啟 TurnController.swift 並用以下內容替換:
class TurnController {
var currentTurn: Turn?
var pastTurns: [Turn] = [Turn]()
// 1
init(turnStrategy: TurnStrategy) {
self.turnStrategy = turnStrategy
}
func beginNewTurn() -> (ShapeView, ShapeView) {
// 2
let shapeViews = turnStrategy.makeShapeViewsForNextTurnGivenPastTurns(pastTurns: pastTurns)
currentTurn = Turn(shapes: [shapeViews.0.shape, shapeViews.1.shape])
return shapeViews
}
func endTurnWithTappedShape(tappedShape: Shape) -> Int {
currentTurn!.turnCompletedWithTappedShape(tappedShape: tappedShape)
pastTurns.append(currentTurn!)
let scoreIncrement = currentTurn!.matched! ? 1 : -1
return scoreIncrement
}
private let turnStrategy: TurnStrategy
}
複製程式碼
以下是詳細步驟:
-
接收傳遞的策略並將其儲存在
TurnController
例項中。 -
使用策略生成
ShapeView
物件,以便玩家可以開始新的回合。
注意: 這將會導致 GameViewController.swift 中出現語法錯誤。但是別擔心,這只是暫時的,你將在下一步中修復錯誤。
使用 策略 設計模式的最後一步是調整你的 GameViewController
從而來使用你的 TurnStrategy
。
開啟 GameViewController.swift 並用以下內容替換:
import UIKit
class GameViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 1
let squareShapeViewFactory = SquareShapeViewFactory(size: gameView.sizeAvailableForShapes())
let squareShapeFactory = SquareShapeFactory(minProportion: 0.3, maxProportion: 0.8)
let squareShapeViewBuilder = shapeViewBuilderForFactory(shapeViewFactory: squareShapeViewFactory)
let squareTurnStrategy = BasicTurnStrategy(shapeFactory: squareShapeFactory, shapeViewBuilder: squareShapeViewBuilder)
// 2
let circleShapeViewFactory = CircleShapeViewFactory(size: gameView.sizeAvailableForShapes())
let circleShapeFactory = CircleShapeFactory(minProportion: 0.3, maxProportion: 0.8)
let circleShapeViewBuilder = shapeViewBuilderForFactory(shapeViewFactory: circleShapeViewFactory)
let circleTurnStrategy = BasicTurnStrategy(shapeFactory: circleShapeFactory, shapeViewBuilder: circleShapeViewBuilder)
// 3
let randomTurnStrategy = RandomTurnStrategy(firstStrategy: squareTurnStrategy, secondStrategy: circleTurnStrategy)
// 4
turnController = TurnController(turnStrategy: randomTurnStrategy)
beginNextTurn()
}
override var prefersStatusBarHidden: Bool {
return true
}
private func shapeViewBuilderForFactory(shapeViewFactory: ShapeViewFactory) -> ShapeViewBuilder {
let shapeViewBuilder = ShapeViewBuilder(shapeViewFactory: shapeViewFactory)
shapeViewBuilder.fillColor = UIColor.brown
shapeViewBuilder.outlineColor = UIColor.orange
return shapeViewBuilder
}
private func beginNextTurn() {
let shapeViews = turnController.beginNewTurn()
shapeViews.0.tapHandler = { tappedView in
self.gameView.score += self.turnController.endTurnWithTappedShape(tappedShape: tappedView.shape)
self.beginNextTurn()
}
shapeViews.1.tapHandler = shapeViews.0.tapHandler
gameView.addShapeViews(newShapeViews: shapeViews)
}
private var gameView: GameView { return view as! GameView }
private var turnController: TurnController!
}
複製程式碼
你修改後的 GameViewController
使用 TurnStrategy
的詳細步驟如下:
-
建立一個策略來建立正方形。
-
建立一個策略來建立圓形。
-
建立策略來隨機選擇是使用正方形還是圓形策略。
-
建立回合控制器來使用隨機策略。
編譯並執行,然後玩五到六輪,你應該看到類似於以下的內容。
請注意你的遊戲是如何在正方形和圓形之間隨機交替的。此時你可以輕鬆地新增第三個形狀來,如三角形或平行四邊形,你的 GameViewController
可以通過切換策略來使用它。
責任鏈,命令和迭代器模式
考慮一下本教程開頭的示例:
var collection = ...
// for 迴圈使用迭代器設計模式
for item in collection {
print("Item is: \(item)")
}
複製程式碼
是什麼使得 for item in collection
這個迴圈工作的呢?答案是 Swift 的 SequenceType
。
通過在 for ... in
迴圈中使用 迭代器 模式,你可以迭代任何遵循 SequenceType
協議的型別。
內建的集合型別 Array
和 Dictionary
是遵循 SequenceType
的,因此除非你要編寫自己的集合,否則通常不需要考慮 SequenceType
。不過我仍然很高興瞭解這個模式。:]
你經常看到的與 迭代器 結合使用的另一種設計模式是 命令 模式,它會捕獲在被詢問時在目標上呼叫特定行為的概念。
在本教程中你將使用 命令 來確定一個 回合
的勝負並計算遊戲的分數。
建立一個名為 Scorer.swift 的新檔案,並使用以下程式碼替換:
// 1
protocol Scorer {
func computeScoreIncrement<S>(_ pastTurnsReversed: S) -> Int where S : Sequence, Turn == S.Iterator.Element
}
// 2
class MatchScorer: Scorer {
func computeScoreIncrement<S>(_ pastTurnsReversed: S) -> Int where S : Sequence, S.Element == Turn {
var scoreIncrement: Int?
// 3
for turn in pastTurnsReversed {
if scoreIncrement == nil {
// 4
scoreIncrement = turn.matched! ? 1 : -1
break
}
}
return scoreIncrement ?? 0
}
}
複製程式碼
依次來看看每一步:
-
定義你的 命令 型別並宣告它的行為讓它接收一個你可以用 迭代器 來迭代的過去所有回合的集合。
-
一個
Scorer
的具體實現,根據它們是否獲勝來計算得分。 -
使用 迭代器 迭代過去的回合。
-
將獲勝回合的得分計為 +1,輸掉的回合得分為 -1。
現在開啟 TurnController.swift 並在類的最底部新增以下程式碼:
private var scorer: Scorer
複製程式碼
然後將以下程式碼新增到初始化器 init(turnStrategy:)
的末尾:
self.scorer = MatchScorer()
複製程式碼
Finally, replace the line in endTurnWithTappedShape
that declares and sets scoreIncrement
with the following:最後把 endTurnWithTappedShape
裡 scoreIncrement
的宣告替換為:
let scoreIncrement = scorer.computeScoreIncrement(pastTurns.reversed())
複製程式碼
注意你將在計算得分之前反轉 pastTurns
,因為計算得分的順序和回合進行的順序相反,而 pastTurns
儲存著最開始的回合,換句話說就是我們將在陣列的最後 append 最新的回合。
編譯並執行專案,你注意到一些奇怪的事了嗎?我打賭你的得分因某種原因沒有改變。
你需要使用 責任鏈 模式來改變你的得分。
責任鏈 模式會捕獲跨一組資料排程多個命令的概念。在本練習中,你將傳送不同的 Scorer
命令來以多種附加方式計算你的玩家得分。
例如你不僅會為比賽的勝負加或減一分,而且還會為連續比賽的連勝獲得獎勵分。責任鏈 允許你以不會打斷現有記分員的方式新增第二個 Scorer
的實現。
開啟 Scorer.swift 並在 MatchScorer
裡的最上方新增以下程式碼:
var nextScorer: Scorer? = nil
複製程式碼
然後在 Scorer
協議的最後新增:
var nextScorer: Scorer? { get set }
複製程式碼
現在 MatchScorer
和其他所有的 Scorer
都表明它們通過 nextScorer
屬性實現了 責任鏈 模式。
在 computeScoreIncrement
裡用以下程式碼替換 return
語句:
return (scoreIncrement ?? 0) + (nextScorer?.computeScoreIncrement(pastTurnsReversed) ?? 0)
複製程式碼
現在你可以在 MatchScorer
之後向鏈中新增另一個 Scorer
並將其得分自動新增到 MatchScorer
計算的分數中。
注意:
??
運算子是 Swift 的 合併空值運算子。如果可選值非 nil 則將其值展開,如果可選值為 nil 則返回 ?? 後的另一個值。實際上a ?? b
與a != nil ? a! : b
一樣。這是一個很好的速記,我們鼓勵你在你的程式碼中使用它。
要來演示這一點,請開啟 Scorer.swift 並將以下程式碼新增到檔案末尾:
class StreakScorer: Scorer {
var nextScorer: Scorer? = nil
func computeScoreIncrement<S>(_ pastTurnsReversed: S) -> Int where S : Sequence, S.Element == Turn {
// 1
var streakLength = 0
for turn in pastTurnsReversed {
if turn.matched! {
// 2
streakLength += 1
} else {
// 3
break
}
}
// 4
let streakBonus = streakLength >= 5 ? 10 : 0
return streakBonus + (nextScorer?.computeScoreIncrement(pastTurnsReversed) ?? 0)
}
}
複製程式碼
你漂亮的新的 StreakScorer
工作原理如下:
-
連續獲勝的次數。
-
如果該回合獲勝,則連續次數加一。
-
如果該回合輸了,則連續獲勝次數清零。
-
計算連勝獎勵,連勝 5 場或更多場獎勵 10 分!
要完成 責任鏈 模式,開啟 TurnController.swift 並將以下行新增到初始化器 init(turnStrategy:)
的末尾:
self.scorer.nextScorer = StreakScorer()
複製程式碼
很好,現在你正在使用 責任鏈。
編譯並執行,在前五個回合都獲勝的情況下,你應該看到如下截圖。
請注意分數是如何從 5 一下子變成 16 的,因為連勝五局,計算獎勵分 10 分和第六局獲得的 1 分所以一共是 16 分。
接下來該幹嘛?
這裡是本次教程的 最終專案。
你玩了一個有趣的遊戲 Tap the Larger Shape 並使用設計模式來新增更多的形狀以及增強其樣式,你還使用了設計模式來更精確地計算得分。
最值得注意的是,即使最終專案具有更多功能,其程式碼實際上比你開始時更簡單且更易於維護。
為什麼不使用這些設計模式進來一步擴充套件你的遊戲呢?可以嘗試一下下面的想法。
新增更多形狀,如三角形、平行四邊形、星形等 提示:回想一下如何新增圓形,並按照類似的步驟順序新增新形狀。如果你想出一些非常酷的形狀,你也可以自己嘗試一下!
新增分數變化時的動畫
提示:在 GameView.score
上使用 didSet
。
新增控制元件來讓玩家選擇遊戲使用的形狀型別
提示:在 GameView
中新增三個 UIButton
或一個帶有 Square、Circle 和 Mixed 三個選項的 UISegmentedControl
,它們應該將控制元件上的任何點選事件通過閉包轉發給 觀察者。GameViewController
可以使用這些閉包來調整它使用的 TurnStrategy
。
將形狀設定保留為可以恢復的首選項
提示:將玩家選擇的形狀型別儲存在 UserDefaults
中。嘗試使用一下 外觀 模式(詳細說明)來隱藏你對其他人的永續性機制的選擇。
允許玩家選擇遊戲的配色方案
提示:使用 UserDefaults
來持久化儲存玩家的選擇。建立一個可以接受持久選擇並相應地調整應用程式的 UI 的 ShapeViewBuilder
。當配色方案更改時,你是否可以使用 NotificationCenter
來通知所有相關的 view 來作出相應的更新呢?
每當玩家獲勝時發出慶祝的鈴聲,失敗時發出悲傷的鈴聲
提示:擴充套件 GameView
和 GameViewController
之間使用的 觀察者 模式。
使用依賴注入將 Scorer 傳遞給 TurnController
提示:從初始化器中移除對 MatchScorer
和 StreakScorer
的引用。
感謝你完成本教程!你可以在評論區分享你的問題和想法以及提升遊戲逼格的方法。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。