這篇博客談一下在實(shí)際項目中我們?nèi)绾螆?zhí)行重構(gòu)。
首先我們明確一下重構(gòu)的目標(biāo)是什么?重構(gòu)是為了讓項目中的代碼易懂,易維護(hù)。我覺得有一些像家居中的收納。假設(shè)你有一個抽屜,現(xiàn)在你只有一樣?xùn)|西。那么需要去整理收納嗎?其實(shí)意義不大,因為任何人只要打開抽屜,就能知道里面裝了什么。但是隨著業(yè)務(wù)需求的增長,抽屜里的東西越來越多,往里面放東西的人也越來越多。終于過了一個臨界點(diǎn),任何一個人要往抽屜里找東西都越來越難。
所以我們需要保持秩序。這是收納,也是重構(gòu)。
下面以我在重構(gòu)自定義地圖控件中項目里看到的一段代碼為例,來說明一下重構(gòu)如何執(zhí)行。
首先介紹一下需求:在地圖上我們要繪制一個多邊形,多邊形的頂點(diǎn)需要支持拖動,每次頂點(diǎn)被拖動后,多邊形區(qū)域就需要重新繪制。為了讓用戶在編輯區(qū)域的時候更加友好,在編輯時我們還會展示每條邊的邊長。
下面的代碼的作用就是繪制多邊形。
class CustomMapOverlayView: UIView {
var polygonEditPointViews = [PolygonAnnotationView]()
var polygonLayer = CAShapeLayer()
var distanceMarkerLayer = CAShapeLayer()
private func drawPolygon(currentIndex: Int = 0, showDistanceMarker: Bool = true) {
guard polygonControlPointViews.count >= 3 else { return }
polygonEditPointViews.forEach { $0.removeFromSuperview() }
polygonEditPointViews.removeAll()
var distanceMarkerLayers = [CAShapeLayer]()
let polygonPath = UIBezierPath()
for (index, polygonControlPointView) in polygonControlPointViews.enumerated() {
if index == 0 {
polygonPath.move(to: polygonControlPointView.center)
}
let nextIndex = (index + 1) % polygonControlPointViews.count
let nextControlPoint = polygonControlPointViews[nextIndex].center
polygonPath.addLine(to: nextControlPoint)
let editPoint = GeometryHelper.getMiddlePoint(point1: polygonControlPointView.center, point2: polygonControlPointViews[nextIndex].center)
addPolygonPointView(center: editPoint, type: .add)
if showDistanceMarker {
let nextCoordinate = currentPolygonVertexes[nextIndex].coordinate
let markerDistance = GeometryHelper.getDistance(from: currentPolygonVertexes[index].coordinate, to: nextCoordinate).rounded()
let markerLayer = drawDistanceMarkerLayer(centerPoint: editPoint, text: String(format: "%.0f", markerDistance) + "m")
distanceMarkerLayers.append(markerLayer)
}
}
polygonPath.close()
polygonLayer.removeFromSuperlayer()
polygonLayer.path = polygonPath.cgPath
polygonLayer.lineWidth = 1
// 判斷多邊形是否合法,不合法則將線段以紅色顯示
polygonLayer.strokeColor = isPolygonValid(index: currentIndex) ? polygonColor.cgColor : UIColor.red.cgColor
polygonLayer.fillColor = polygonColor.cgColor
polygonLayer.zPosition -= 1
layer.addSublayer(polygonLayer)
// 添加距離標(biāo)記
distanceMarkerLayer.sublayers?.removeAll()
distanceMarkerLayer.removeFromSuperlayer()
distanceMarkerLayers.forEach {
distanceMarkerLayer.addSublayer($0)
}
distanceMarkerLayer.zPosition -= 1
layer.addSublayer(distanceMarkerLayer)
}
private func drawDistanceMarkerLayer(centerPoint: CGPoint, text: String) -> CAShapeLayer {
let textSize = getTextSize(text: text)
let react = CGRect(x: centerPoint.x - 8, y: centerPoint.y - 8, width: textSize.width + 24, height: 16)
let roundRectPath = UIBezierPath(roundedRect: react, cornerRadius: 8)
let markerLayer = CAShapeLayer()
markerLayer.path = roundRectPath.cgPath
markerLayer.fillColor = UIColor.white.cgColor
let textLayer = drawTextLayer(frame: CGRect(x: react.origin.x + 18, y: react.origin.y + (8 - textSize.height/2), width: textSize.width, height: textSize.height), text: text, foregroundColor: MeshColor.grey2, backgroundColor: UIColor.clear)
markerLayer.addSublayer(textLayer)
return markerLayer
}
}
上面這段代碼非常明顯的 bad smell 就是太長,大概有四十行。通常情況下一個方法長度超過 20 行意味著做了太多事。當(dāng)然也有一些情況方法長一點(diǎn)是可以接受的。假設(shè)我們有一個抽屜,抽屜裝的都是同一樣?xùn)|西,雖然把抽屜裝滿了,但是對于這個抽屜里裝了什么還是一目了然。如果方法長,但是方法里只是單一的做類似的、很容易理解的事也可以接受。
上面代碼第二個問題是代碼中的抽象層次不一致。我舉個例子,假設(shè)公司的 CEO 做了一個決策,他打算通知所有高管,然后高管再逐級同步給部門。但是 CEO 在通知完高管后,詢問高管,這個決策你要通知的人有誰。高管說要通知 A、B、C。于是 CEO 在高管會上把 A、B、C 叫來告訴了他們這個決策。代碼的抽象層級也是類似,本來在處理頂層的邏輯,接著代碼直接去處理了下一層的細(xì)節(jié)。這樣不同層級的代碼在一個方法里會加大理解的難度。
現(xiàn)在我們開始一步步重構(gòu)這段代碼。
如果大家看了前面幾篇地圖的控件設(shè)計實(shí)現(xiàn)的文章,會發(fā)現(xiàn)這個方法還有一個結(jié)構(gòu)上的問題。多邊形的頂點(diǎn)位置是從 polygonEditPointViews 上取的。但是如果仔細(xì)思考一下,其實(shí)這個方法依賴的是頂點(diǎn)的位置,現(xiàn)在通過依賴 polygonEditPointViews 間接得到,這樣多了不必要的依賴。多了這層不必要的依賴會增加代碼的不穩(wěn)定性,另外如果要隔離測試這個方法,隔離的代價也會更高。
那么我們首先做一個小改動,移除對 polygonEditPointViews 的依賴。可以修改方法的參數(shù),把頂點(diǎn)坐標(biāo)當(dāng)做參數(shù)傳進(jìn)來。如果類的規(guī)模小,直接封裝一個屬性提供頂點(diǎn)坐標(biāo)也可以。這里我選擇比較直觀的封裝屬性方式隔離。
class CustomMapOverlayView: UIView {
var polygonEditPointViews = [PolygonAnnotationView]()
private var areaVertexs: [CGPoint] {
return polygonControlPointViews.map { $0.center }
}
private func drawPolygon(currentIndex: Int = 0, showDistanceMarker: Bool = true) {
guard areaVertexs.count >= 3 else { return }
polygonEditPointViews.forEach { $0.removeFromSuperview() }
polygonEditPointViews.removeAll()
var distanceMarkerLayers = [CAShapeLayer]()
let polygonPath = UIBezierPath()
for (index, vertex) in areaVertexs.enumerated() {
if index == 0 {
polygonPath.move(to: vertex)
}
let nextIndex = (index + 1) % areaVertexs.count
let nextControlPoint = areaVertexs[nextIndex]
polygonPath.addLine(to: nextControlPoint)
let editPoint = GeometryHelper.getMiddlePoint(point1: vertex, point2: areaVertexs[nextIndex])
addPolygonPointView(center: editPoint, type: .add)
// ...
}
}
// ...
}
}
這樣代碼的可讀性也好了一點(diǎn),讀的時候不要去關(guān)心 polygonEditPointViews。
這段代碼主要做了三件事:繪制多邊形,在多邊形邊的中點(diǎn)顯示邊距,在邊上添加增加點(diǎn)的按鈕。實(shí)現(xiàn)的時候三件事的實(shí)現(xiàn)細(xì)節(jié)又寫在了一起。因此讀起來感覺代碼有多有亂。
我們首先隔離繪制多邊形的代碼。
var polygonLayer = CAShapeLayer()
private func drawPolygon(currentIndex: Int = 0, showDistanceMarker: Bool = true) {
guard areaVertexs.count >= 3 else { return }
renderPolygonLayer(changedPointIndex: currentIndex)
polygonEditPointViews.forEach { $0.removeFromSuperview() }
polygonEditPointViews.removeAll()
var distanceMarkerLayers = [CAShapeLayer]()
for (index, vertex) in areaVertexs.enumerated() {
let nextIndex = (index + 1) % areaVertexs.count
let editPoint = GeometryHelper.getMiddlePoint(point1: vertex, point2: areaVertexs[nextIndex])
addPolygonPointView(center: editPoint, type: .add)
if showDistanceMarker {
let nextCoordinate = currentPolygonVertexes[nextIndex].coordinate
let markerDistance = GeometryHelper.getDistance(from: currentPolygonVertexes[index].coordinate, to: nextCoordinate).rounded()
let markerLayer = drawDistanceMarkerLayer(centerPoint: editPoint, text: String(format: "%.0f", markerDistance) + "m")
distanceMarkerLayers.append(markerLayer)
}
}
// 添加距離標(biāo)記
distanceMarkerLayer.sublayers?.removeAll()
distanceMarkerLayer.removeFromSuperlayer()
distanceMarkerLayers.forEach {
distanceMarkerLayer.addSublayer($0)
}
distanceMarkerLayer.zPosition -= 1
layer.addSublayer(distanceMarkerLayer)
}
private func renderPolygonLayer(changedPointIndex: Int = 0) {
let polygonPath = UIBezierPath()
polygonPath.move(to: areaVertexs[0])
for index in 1 ..< areaVertexs.count {
let nextIndex = (index + 1) % areaVertexs.count
let nextControlPoint = areaVertexs[nextIndex]
polygonPath.addLine(to: nextControlPoint)
}
polygonPath.close()
polygonLayer.removeFromSuperlayer()
polygonLayer.path = polygonPath.cgPath
polygonLayer.lineWidth = 1
// 判斷多邊形是否合法,不合法則將線段以紅色顯示
polygonLayer.strokeColor = isPolygonValid(index: changedPointIndex) ? polygonColor.cgColor : UIColor.red.cgColor
polygonLayer.fillColor = polygonColor.cgColor
polygonLayer.zPosition -= 1
layer.addSublayer(polygonLayer)
}
把繪制多邊形的代碼抽離出來后邏輯已經(jīng)清晰很多了。
接著我們先重構(gòu)一下 drawDistanceMarkerLayer方法。這個方法有兩個問題:
- 方法的名字不恰當(dāng)。這個方法的作用是創(chuàng)建了一個 layer,并沒有 draw 這個動作。因此名字要修改,以免引起歧義。
- 方法的參數(shù)不夠好,將參數(shù)的處理細(xì)節(jié)暴露在了外面。這個方法被調(diào)用的地方只有一處,參數(shù)應(yīng)該讓調(diào)用的地方盡量簡潔。字符格式的配置應(yīng)該在方法內(nèi)完成。
重構(gòu)完成后調(diào)用的地方是這樣的:
let markerLayer = createDistanceMarkerLayer(centerPoint: editPoint, markerDistance: markerDistance)
//原來的調(diào)用
let markerLayer = drawDistanceMarkerLayer(centerPoint: editPoint, text: String(format: "%.0f", markerDistance) + "m")
接著我們把距離標(biāo)記再抽出來。
private func drawPolygon(currentIndex: Int = 0, showDistanceMarker: Bool = true) {
guard areaVertexs.count >= 3 else { return }
renderPolygonLayer(changedPointIndex: currentIndex)
polygonEditPointViews.forEach { $0.removeFromSuperview() }
polygonEditPointViews.removeAll()
for (index, vertex) in areaVertexs.enumerated() {
let nextIndex = (index + 1) % areaVertexs.count
let editPoint = GeometryHelper.getMiddlePoint(point1: vertex, point2: areaVertexs[nextIndex])
addPolygonPointView(center: editPoint, type: .add)
}
if showDistanceMarker {
renderDistanceMarkerLayer()
}
}
private func renderDistanceMarkerLayer() {
var distanceMarkerLayers = [CAShapeLayer]()
for index in 0 ..< areaVertexs.count {
let nextIndex = (index + 1) % areaVertexs.count
let middlePoint = GeometryHelper.getMiddlePoint(point1: areaVertexs[index], point2: areaVertexs[nextIndex])
let nextCoordinate = currentPolygonVertexes[nextIndex].coordinate
let markerDistance = GeometryHelper.getDistance(from: currentPolygonVertexes[index].coordinate, to: nextCoordinate).rounded()
let markerLayer = createDistanceMarkerLayer(centerPoint: middlePoint, markerDistance: markerDistance)
distanceMarkerLayers.append(markerLayer)
}
// 添加距離標(biāo)記
distanceMarkerLayer.sublayers?.removeAll()
distanceMarkerLayer.removeFromSuperlayer()
distanceMarkerLayers.forEach {
distanceMarkerLayer.addSublayer($0)
}
distanceMarkerLayer.zPosition -= 1
layer.addSublayer(distanceMarkerLayer)
}
做完這一步 drawPolygon 里的代碼行數(shù)已經(jīng)很少了,只有不到 10 行。在這個體量下前面說到舊代碼問題的第二點(diǎn)就比較明顯了:中間的繪制增加點(diǎn)的按鈕和其他的層次不同,繪制增加點(diǎn)直接把實(shí)現(xiàn)寫在這里了,抽象層次直接降低了。一個頂層方法應(yīng)該負(fù)責(zé)調(diào)度,細(xì)節(jié)的實(shí)現(xiàn)不應(yīng)該在里面。
最后我們把繪制增加點(diǎn)的按鈕抽離出來。
private func drawPolygon(currentIndex: Int = 0, showDistanceMarker: Bool = true) {
guard areaVertexs.count >= 3 else { return }
renderPolygonLayer(changedPointIndex: currentIndex)
renderEditPoints()
if showDistanceMarker {
renderDistanceMarkerLayer()
}
}
private func renderEditPoints() {
polygonEditPointViews.forEach { $0.removeFromSuperview() }
polygonEditPointViews.removeAll()
for (index, vertex) in areaVertexs.enumerated() {
let nextIndex = (index + 1) % areaVertexs.count
let editPoint = GeometryHelper.getMiddlePoint(point1: vertex, point2: areaVertexs[nextIndex])
let polygonPoint = createPolygonPoint(center: editPoint, type: .add)
addSubview(polygonPoint)
polygonEditPointViews.append(polygonPoint)
}
}
完成后核心方法 drawPolygon 只有 5 行代碼,這個方法做了什么應(yīng)該非常清晰易理解了。子方法中負(fù)責(zé)各自繪制的部分。如果后期要繪制其他元素,在 drawPolygon 中增加。如果元素的 UI 有變化,到各個負(fù)責(zé)具體繪制的方法中修改也不會影響到其他模塊。
重構(gòu)的指導(dǎo)思想是什么?按照一種邏輯整理劃分代碼,把每塊代碼的體量控制在一個容易理解的范圍里。