Swift 程式碼質量指標

語言: CN / TW / HK

image.png

以上是一些常見的程式碼質量指標。我們的目標是如何更好的使用Swift編寫出符合程式碼質量指標要求的程式碼。

提示:本文不涉及設計模式/架構,更多關注如何通過合理使用Swift特性做部分程式碼段的重構。

一些不錯的實踐

1. 利用編譯檢查

減少使用Any/AnyObject

因為Any/AnyObject缺少明確的型別資訊,編譯器無法進行型別檢查,會帶來一些問題:

  • 編譯器無法檢查型別是否正確保證型別安全
  • 程式碼中大量的as?轉換
  • 型別的缺失導致編譯器無法做一些潛在的編譯優化
使用as?帶來的問題

當使用Any/AnyObject時會頻繁使用as?進行型別轉換。這好像沒什麼問題因為使用as?並不會導致程式Crash。不過程式碼錯誤至少應該分為兩類,一類是程式本身的錯誤通常會引發Crash,另外一種是業務邏輯錯誤。使用as?只是避免了程式錯誤Crash,但是並不能防止業務邏輯錯誤。

``` func do(data: Any?) { guard let string = data as? String else { return } // }

do(1) do("") ```

以上面的例子為例,我們進行了as?轉換,當dataString時才會進行處理。但是當do方法內String型別發生了改變函式,使用方並不知道已變更沒有做相應的適配,這時候就會造成業務邏輯的錯誤。

提示:這類錯誤通常更難發現,這也是我們在一次真實bug場景遇到的。

使用自定義型別代替Dictionary

程式碼中大量Dictionary資料結構會降低程式碼可維護性,同時帶來潛在的bug

  • key需要字串硬編碼,編譯時無法檢查
  • value沒有型別限制。修改時型別無法限制,讀取時需要重複型別轉換和解包操作
  • 無法利用空安全特性,指定某個屬性必須有值

提示:自定義型別還有個好處,例如JSON自定義型別時會進行型別/nil/屬性名檢查,可以避免將錯誤資料丟到下一層。

不推薦

let dic: [String: Any] let num = dic["value"] as? Int dic["name"] = "name"

推薦

struct Data { let num: Int var name: String? } let num = data.num data.name = "name"

適合使用Dictionary的場景
  • 資料不使用 - 資料並不讀取只是用來傳遞。
  • 解耦 
    • 1.元件間通訊解耦使用HashMap傳遞引數進行通訊。
    • 2.跨技術棧邊界的場景,混合棧間通訊/前後端通訊使用HashMap/JSON進行通訊。

使用列舉關聯值代替Any

例如使用列舉改造NSAttributedStringAPI,原有APIvalueAny型別無法限制特定的型別。

優化前

let string = NSMutableAttributedString() string.addAttribute(.foregroundColor, value: UIColor.red, range: range)

改造後

enum NSAttributedStringKey { case foregroundColor(UIColor) } let string = NSMutableAttributedString() string.addAttribute(.foregroundColor(UIColor.red), range: range) // 不傳遞Color會報錯

使用泛型/協議關聯型別代替Any

使用泛型協議關聯型別代替Any,通過泛型型別約束來使編譯器進行更多的型別檢查。

_

使用列舉/常量代替硬編碼

程式碼中存在重複的硬編碼字串/數字,在修改時可能會因為不同步引發bug。儘可能減少硬編碼字串/數字,使用列舉常量代替。

使用KeyPath代替字串硬編碼

KeyPath包含屬性名和型別資訊,可以避免硬編碼字串,同時當屬性名或型別改變時編譯器會進行檢查。

不推薦

class SomeClass: NSObject { @objc dynamic var someProperty: Int init(someProperty: Int) { self.someProperty = someProperty } } let object = SomeClass(someProperty: 10) object.observeValue(forKeyPath: "", of: nil, change: nil, context: nil)

推薦

let object = SomeClass(someProperty: 10) object.observe(.someProperty) { object, change in }

2. 記憶體安全

!屬性會在讀取時隱式強解包,當值不存在時產生執行時異常導致Crash。

class ViewController: UIViewController { @IBOutlet private var label: UILabel! // @IBOutlet需要使用! }

減少使用!進行強解包

使用!強解包會在值不存在時產生執行時異常導致Crash。

var num: Int? let num2 = num! // 錯誤

提示:建議只在小範圍的區域性程式碼段使用!強解包。

避免使用try!進行錯誤處理

使用try!會在方法丟擲異常時產生執行時異常導致Crash。

try! method()

使用weak/unowned避免迴圈引用 √

``` resource.request().onComplete { [weak self] response in guard let self = self else { return } let model = self.updateModel(response) self.updateUI(model) }

resource.request().onComplete { [unowned self] response in let model = self.updateModel(response) self.updateUI(model) } ```

減少使用unowned

unowned在值不存在時會產生執行時異常導致Crash,只有在確定self一定會存在時才使用unowned

class Class { @objc unowned var object: Object @objc weak var object: Object? }

unowned/weak區別:

  • weak - 必須設定為可選值,會進行弱引用處理效能更差。會自動設定為nil
  • unowned - 可以不設定為可選值,不會進行弱引用處理效能更好。但是不會自動設定為nil, 如果self已釋放會觸發錯誤.

錯誤處理方式

  • 可選值 - 呼叫方並不關注內部可能會發生錯誤,當發生錯誤時返回nil
  • try/catch - 明確提示呼叫方需要處理異常,需要實現Error協議定義明確的錯誤型別
  • assert - 斷言。只能在Debug模式下生效
  • precondition - 和assert類似,可以再Debug/Release模式下生效
  • fatalError - 產生執行時崩潰會導致Crash,應避免使用
  • Result - 通常用於閉包非同步回撥返回值

減少使用可選值

可選值的價值在於通過明確標識值可能會為nil並且編譯器強制對值進行nil判斷。但是不應該隨意的定義可選值,可選值不能用let定義,並且使用時必須進行解包操作相對比較繁瑣。在程式碼設計時應考慮這個值是否有可能為nil,只在合適的場景使用可選值。

使用init注入代替可選值屬性

不推薦

class Object { var num: Int? } let object = Object() object.num = 1

推薦

``` class Object { let num: Int

init(num: Int) { self.num = num } } let object = Object(num: 1) ```

避免隨意給予可選值預設值

在使用可選值時,通常我們需要在可選值為nil時進行異常處理。有時候我們會通過給予可選值預設值的方式來處理。但是這裡應考慮在什麼場景下可以給予預設值。在不能給予預設值的場景應當及時使用return丟擲異常,避免錯誤的值被傳遞到更多的業務流程。

不推薦

func confirmOrder(id: String) {} // 給予錯誤的值會導致錯誤的值被傳遞到更多的業務流程 confirmOrder(id: orderId ?? "")

推薦

``` func confirmOrder(id: String) {}

guard let orderId = orderId else { // 異常處理 return } confirmOrder(id: orderId) ```

提示:通常強業務相關的值不能給予預設值:例如商品/訂單id或是價格。在可以使用兜底邏輯的場景使用預設值,例如預設文字/文字顏色

使用列舉優化可選值

前提Object結構同時只會有一個值存在:

優化前

class Object { var name: Int? var num: Int? }

優化後
  • 降低記憶體佔用 - 列舉關聯型別的大小取決於最大的關聯型別大小
  • 邏輯更清晰 - 使用enum相比大量使用if/else邏輯更清晰

enum CustomType { case name(String) case num(Int) }

減少var屬性

使用計算屬性

使用計算屬性可以減少多個變數同步帶來的潛在bug。

不推薦

class model { var data: Object? var loaded: Bool } model.data = Object() loaded = false 複製程式碼

推薦

``` class model { var data: Object? var loaded: Bool { return data != nil } } model.data = Object()

```

提示:計算屬性因為每次都會重複計算,所以計算過程需要輕量避免帶來效能問題。

使用filter/reduce/map代替for迴圈

使用filter/reduce/map可以帶來很多好處,包括更少的區域性變數,減少模板程式碼,程式碼更加清晰,可讀性更高。

不推薦

let nums = [1, 2, 3] var result = [] for num in nums { if num < 3 { result.append(String(num)) } } // result = ["1", "2"]

推薦

let nums = [1, 2, 3] let result = nums.filter { $0 < 3 }.map { String($0) } // result = ["1", "2"]

使用guard進行提前返回

推薦

guard !a else { return } guard !b else { return } // do

不推薦

if a { if b { // do } }

使用三元運算子?:

推薦

``` let b = true let a = b ? 1 : 2

let c: Int? let b = c ?? 1 複製程式碼 ```

不推薦

var a: Int? if b { a = 1 } else { a = 2 }

使用for where優化迴圈

for迴圈新增where語句,只有當where條件滿足時才會進入迴圈

不推薦

for item in collection { if item.hasProperty { // ... } }

推薦

for item in collection where item.hasProperty { // item.hasProperty == true,才會進入迴圈 }

使用defer

defer可以保證在函式退出前一定會執行。可以使用defer中實現退出時一定會執行的操作例如資源釋放等避免遺漏。

func method() { lock.lock() defer { lock.unlock() // 會在method作用域結束的時候呼叫 } // do }

字串

使用"""

在定義複雜字串時,使用多行字串字面量可以保持原有字串的換行符號/引號等特殊字元,不需要使用``進行轉義。

``` let quotation = """ The White Rabbit put on his spectacles. "Where shall I begin, please your Majesty?" he asked.

"Begin at the beginning," the King said gravely, "and go on till you come to the end; then stop." """ 複製程式碼 ```

提示:上面字串中的""和換行可以自動保留。

使用字串插值

使用字串插值可以提高程式碼可讀性。

不推薦

let multiplier = 3 let message = String(multiplier) + "times 2.5 is" + String((Double(multiplier) * 2.5))

推薦

let multiplier = 3 let message = "(multiplier) times 2.5 is (Double(multiplier) * 2.5)"

集合

使用標準庫提供的高階函式

不推薦

var nums = [] nums.count == 0 nums[0] 複製程式碼

推薦

var nums = [] nums.isEmpty nums.first

訪問控制

Swift中預設訪問控制級別為internal。編碼中應當儘可能減小屬性/方法/型別的訪問控制級別隱藏內部實現。

提示:同時也有利於編譯器進行優化。

使用private/fileprivate修飾私有屬性方法

private let num = 1 class MyClass { private var num: Int }

使用private(set)修飾外部只讀/內部可讀寫屬性

class MyClass { private(set) var num = 1 } let num = MyClass().num MyClass().num = 2 // 會編譯報錯

函式

使用引數預設值

使用引數預設值,可以使呼叫方傳遞更少的引數。

不推薦

func test(a: Int, b: String?, c: Int?) { } test(1, nil, nil)

推薦

func test(a: Int, b: String? = nil, c: Int? = nil) { } test(1)

提示:相比ObjC引數預設值也可以讓我們定義更少的方法。

限制引數數量

當方法引數過多時考慮使用自定義型別代替。

不推薦

func f(a: Int, b: Int, c: Int, d: Int, e: Int, f: Int) { } 複製程式碼

推薦

struct Params { let a, b, c, d, e, f: Int } func f(params: Params) { }

使用@discardableResult

某些方法使用方並不一定會處理返回值,可以考慮新增@discardableResult標識提示Xcode允許不處理返回值不進行warning提示。

``` // 上報方法使用方不關心是否成功 func report(id: String) -> Bool {}

@discardableResult func report2(id: String) -> Bool {}

report("1") // 編譯器會警告 report2("1") // 不處理返回值編譯器不會警告 ```

元組

避免過長的元組

元組雖然具有型別資訊,但是並不包含變數名資訊,使用方並不清晰知道變數的含義。所以當元組數量過多時考慮使用自定義型別代替。

``` func test() -> (Int, Int, Int) {

} let (a, b, c) = test() // a,b,c型別一致,沒有命名資訊不清楚每個變數的含義 print("a (a), b: (b), c: (c) ") ```

系統庫

KVO/Notification 使用 block API

block API的優勢:

  • KVO 可以支援 KeyPath
  • 不需要主動移除監聽,observer釋放時自動移除監聽
不推薦

``` class Object: NSObject { init() { super.init() addObserver(self, forKeyPath: "value", options: .new, context: nil) NotificationCenter.default.addObserver(self, selector: #selector(test), name: NSNotification.Name(rawValue: ""), object: nil) }

override class func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { }

@objc private func test() { }

deinit { removeObserver(self, forKeyPath: "value") NotificationCenter.default.removeObserver(self) } } ```

推薦

``` class Object: NSObject {

private var observer: AnyObserver? private var kvoObserver: NSKeyValueObservation?

init() { super.init() observer = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: ""), object: nil, queue: nil) { (_) in } kvoObserver = foo.observe(.value, options: [.new]) { (foo, change) in } } } ```

Protocol

使用protocol代替繼承

Swift中針對protocol提供了很多新特性,例如預設實現關聯型別,支援值型別。在程式碼設計時可以優先考慮使用protocol來避免臃腫的父類同時更多使用值型別。

提示:一些無法用protocol替代繼承的場景: - 1.需要繼承NSObject子類。 - 2.需要呼叫super方法。 - 3.實現抽象類的能力。

Extension

使用extension組織程式碼

使用extension私有方法/父類方法/協議方法等不同功能程式碼進行分離更加清晰/易維護。

class MyViewController: UIViewController { // class stuff here } // MARK: - Private extension: MyViewController { private func method() {} } // MARK: - UITableViewDataSource extension MyViewController: UITableViewDataSource { // table view data source methods } // MARK: - UIScrollViewDelegate extension MyViewController: UIScrollViewDelegate { // scroll view delegate methods }

程式碼風格

良好的程式碼風格可以提高程式碼的可讀性,統一的程式碼風格可以降低團隊內相互理解成本。對於Swift的程式碼格式化建議使用自動格式化工具實現,將自動格式化新增到程式碼提交流程,通過定義Lint規則統一團隊內程式碼風格。考慮使用SwiftFormatSwiftLint

提示:SwiftFormat主要關注程式碼樣式的格式化,SwiftLint可以使用autocorrect自動修復部分不規範的程式碼。

常見的自動格式化修正
  • 移除多餘的;
  • 最多隻保留一行換行
  • 自動對齊空格
  • 限制每行的寬度自動換行

作者:何樂樂 連結:https://juejin.cn/post/6984768684250120222\ 來源:稀土掘金 著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

Swift 程式設計官網規範

————————————————————————————————————————————