Browsing Category

iOS

iOS 讀書筆記 軟體開發

錯誤處理之斷言 Assertion – 不要亂用我設計的物件

錯誤是很抽象的字,廣義來說所有不在預期內的結果都算是某種”錯誤”。
接下來會分兩三篇文章來分享最近所學到跟錯誤處理相關的議題,話不多說我們開始吧💪

斷言(Assertion) 是一種條件式檢查語法,當現行狀態不合乎條件時會強迫終止程式。

斷言只用在標示一些邏輯上不可能或不應該出現的情形,若上述情形真的出現時,表示有些基本架構已出現問題。 — wiki 斷言

為什麼會需要斷言

當程式碼執行到不該出現的狀態時,且一但進入此狀態就執行不下去的時候。常用在提醒開發者 API 的使用規範、此程式碼缺少必要圖片、初始狀態資料缺少等等。

白話一點就是『這裡有嚴重錯誤,請你現在把程式碼修好』,舉例來說:
– 汽車應該只能汽油,若是加了沙拉油就會讓車子受損,請不要亂加
– 初始值應該永遠都要存在,若不存在物件無法建立
– 必要圖片 (app icon) 一定要包含在資料夾裡
– function 的輸入值規範在某個範圍 (分數 0-100)
– 虛擬類別的建立(Swift API 目前無法宣告虛擬類別)

一段設計良好的程式碼應留下足夠的前後文相關線索,提醒使用者(Code user) 物件或方法的使用條件。但我們都知道沒有人喜歡看說明書 (Document),所以更高明的做法就是直接用程式碼規範條件,並且在使用者犯規(不合乎規範)時提醒他,而斷言(Assertion)這個語法就是為此而生。

Assert

預設僅在 Debug 編譯下才會作用,在 Production 沒有效能影響。

Use this function for internal sanity checks that are active during testing but do not impact performance of shipping code. —Apple Document

接著我們來看一段規範輸入範圍的斷言範例


// This method only works when score 0-100 (bounds) func update(score: Int) {   assert(score >= 0, "score should never be native value")   assert(score <= 100, "score should never greater than 100")    // Prevent crash   let newScore = min(max(0,score), 100)   print(newScore) }

你可以注意到這裡用 assert 來強制規範 score 必須介於 0 – 100 之間。除此之外,在程式碼後段的 min(max(0,score), 100) 則是用來將範圍外的資料修正,來避免在 production 環境下 Crash 。

AssertionFailure

不該執行到這一行,但是萬一 Production 執行到會忽略

func testAsertionFailure() {
    assertionFailure("nope")
    println("ever") // never be executed when debug.
}

guard score >= 0, score <= 100 else {
  assertionFailure("score out of bounds.")
  // If out of bounds in production will be ignored.
  return
}

db.write(score: score)

在上面這個範例裡,當數值超過範圍時不會寫入 db ,並且在 Config = DEBUG 時丟出 exception 。

Precondition

跟 Assert 很像但作用在 Debug 及 Release 。

// This method only works in 0-100 (bounds)
func update(score: Int) {
  precondition(score >= 0, "score should never be native value")
  precondition(score <= 100, "score should never greater than 100")
  
  let newScore = min(max(0, score), 100) // never excute
  print(newScore)
}

同樣的例子用 Precondition 的話 let newScore 這行就永遠不會被執行了,因為 Precondition 無論在 DEBUG 或 Release 都會生效。

PreconditionFailure

跟 FatalError() 一樣

guard let config = loadConfig() else {
  preconditionFailure("Config couldn't be loaded. Verify that Config.json is Valid")
}

// fatal error: Config couldn’t be loaded. Verify that Config.json is valid.: file /Users/John/AmazingApp/Sources/AppDelegate.swift, line 17


// [Require] powered by Sundell
// https://github.com/JohnSundell/Require
let config = loadConfig().require(hint: "Verify that Config.json is Valid")

FatalError

當某些區塊不想別人存取時,可以使用 fatalError 來明示。(常用在抽象類別。)
fatalError 還有另一個特性,它可以無視 return 關鍵字,請看下面範例。

// Abstract class
class Animal {
  func roar() {
    fatalError("Must override this method")
  }
}

class Cat: Animal {
  override func roar() {
    print("Meow!")
  }
}

enum MyEnum {
    case Value1,Value2,Value3
}

func check(someValue: MyEnum) -> String {
    switch someValue {
    case .Value1:
        return "OK"
    case .Value2:
        return "Maybe OK"
    default:
        // 不加 return 也能編譯通過
        fatalError("Should not show!")
    }
}

它表示不得不實作,但是做一個空實做時,並暗示它永遠不該被使用。

小結

Assertion 是很有用的東西,若是使用得當不僅能夠減少文件跟註解,也能讓後續使用的人能夠有明確指引,並且在超出原始設計的預期範圍能夠及時修正。

有沒有感覺更上一層樓了呢?一起持續學習吧 😀

iOS

在 iOS 使用向量圖 (.pdf)

過去開發 iOS app 需要給2種尺寸的圖(1x, 2x),然而隨著更高的解析度螢幕的到來,恭喜各位要開始出三張圖了。

為什麼要給多張圖?

每一個圖要顯示在螢幕都會使用部分記憶體資源,圖越大佔越多。記憶體是手機上的稀缺資源,能不浪費就不浪費。這個問題在較舊的手機上更為嚴重,通常較舊的手機意味著較小的記憶體配置。

若能呈現的畫素遠比圖片檔案提供的更少,顯然就是一種浪費。

舉例來說,你拿著 iPhone 5 (640×1136 pixels) 試圖顯示更高畫素的圖片 (2000×2000 pixels),再不縮放圖片的情況下你是沒辦法一次顯示完的。

  1. 圖太大 x 螢幕解析度不夠 = 無法完全呈現圖細節
  2. 圖太小 x 螢幕解析度太高 = 能夠完全呈現圖,但會糊掉

假設你是螢幕,而食物是,而你能消化吸收的能力則是解析度

  1. 你一天能消化1k能量,你卻吃了3k的食物,其中有2k就被浪費掉了。
  2. 你一天能消化1k能量,你卻只吃0.5k的食物,能量不夠你一天使用。

為了讓App在舊的裝置上執行依舊順暢不浪費,給足不同解析度的圖是有必要的。

Point 與 Pixels 的關係

Point (以下簡稱 pt) 是一種尺寸單位,它指的是在實際裝置上的一個點的最小單位,在不同解析度螢幕上提供一致的表達單位。

如下圖所示, 25 pt 的圖在不同的螢幕分別代表的是 25×25 px, 50×50 px, 75x75px

分別為不同解析度給不同圖

@3x的到來

隨著螢幕解析度再提昇,難保哪一天不會再出@4x, @5x,Apple也就用vector圖作為因應

44x44 pt vector image

(!! 新版 Xcode 是用 Scales 取代原來的 types)

新版 Xcode 用 Scales 設定向量圖

不是完全向量的向量圖

原圖是 44×44 的向量圖,設定為 44×44 時它可以運作的很好。

44x44 pt 運作良好

但設把 44×44 的圖設為 144×144 時還是會糊掉

糊掉的向量圖

原因是在執行階段,iOS會幫你把向量圖轉成最後要的圖 (輸出@1x, @2x, @3x),而不是直接拿那張向量圖放在畫面上,所以它還是基於 44 x 44 產生出
3種尺寸的圖給 iOS app 使用。

全面使用向量圖嗎?

Xcode 9 可以完整支援向量圖囉! 就大膽用吧!
The Unexpected Joy of Vector Images in iOS 11

iOS

重構 URL Scheme Handler

URL schemes 是操作 app 的指令

把 app 視為一個封裝的資源,URL schemes 是用來存取 app 資源的指令。 它就像是遙控器上的按鈕一樣,你只要知道哪一個按鈕會的到什麼結果,不需要知道背後如何執行。

對我來說它像是設計模式(Design Pattern)裡的命令模式一樣,把功能封裝成指令提供外部操作。

以 KKBOX 為例 kkbox://playctrl_pause 這個指令,就是用來暫停現在播放的歌曲。 指令前面這一段 kkbox:// 指的就是 KKBOX 的 URL Scheme(後續以 Scheme 代稱) ,後面 palyctrl_pause 這段則是代表動作。

當然在這我是不會列出所有指令的 XD

為什麼需要重構 URL handler?

在 KKBOX 這樣的大型專案裡有大約 6x 個 schemes,對你沒看錯是 6x 個。 這麼多指令要如何確保每一條都正常運行? 當然每次出版本前都全部手動測試一次是一個方法,但手動測試不僅慢還有可能會遺漏,身為一個合格的 iOS 工程師當然是要自動化啊。

其實上面這段是比較官方說法,實際情況是幾次出版本前被 QA 測出某些 Scheme 沒有正常運作,來來回回發了好多票。 當問題一再發生的時候一定有哪裡沒做好,需要改善。

Scheme 能夠自動化測試

當我正準備建立測試資料時發現一個問題,那就是我沒有所有 Schemes 的測試案例(沒有人有),所以我沒辦法一開始就建立所有的 UIAutomaciton 測試。

如果一個問題太大,就拆成多個小問題。

延續前一篇文章簡化邏輯讓 UI 不再出錯的概念, 我打算把 schemes 其實轉換成 Swift enum 。

整體解決方案分成 URL ParserURL Handler 兩個部分:

  • Parser: 將 URL Scheme 一對一的轉換成 KKRoute (swift enum) ,並實作 Equatable 介面用單元測試驗證。

  • Handler: 著重於這個指令最後執行的結果是否如預期,由於 KKBOX URL Scheme 相當多元,我們必須透過 UIAutomation 來驗證。

也就是先將 Scheme 轉成 KKRoute ,在驗證 KKRoute 的執行結果。

URL Scheme Parser

需求是能夠把已 Scheme 轉成 KKRoute,未定義的 URL Scheme 也要能判斷。 實例如下:

"kkbox://playctrl_pause" => KKRoute.pause
"kkbox://play_music" => KKRoute.unknown

接著就可以快快樂樂的 TDD 完成這項任務

1.建立 KKRoute 的建構子

// KKRoute.swift
enum KKRoute {
    case pause
    case unknown

    init(urlString: String) {       
        self = .unknown
    }
}

2.建立單元測試 KKRoteTest.swift

// KKRouteTests.swift
class KKRouteTests: XCTestCase {    
    func testPause() {
      // [紅燈] 此時因為還未實作正確的建構子,所以會測試失敗 
        XCTAssertEqual(KKRoute(urlString: "kkbox://playctrl_pause"), .pause)
    }
}

3.補上實作

// KKRoute.swift
enum KKRoute {
    case pause
    case unknown

    init(urlString: String) {
      // 補上實作 
        if urlString == "kkbox://playctrl_pause" {
            self = .pause
            return
        }

        self = .unknown
    }
}

extension KKRoute: Equatable {
    public static func ==(lhs: KKRoute, rhs: KKRoute) -> Bool {
        switch (lhs, rhs) {
        case (.pause, .pause):
            return true
        case (.unknown, .unknown):
          return true
      default: 
        return false
        }
    }
}

// KKRouteTests.swift
class KKRouteTests: XCTestCase {    
    func testPause() {
    // [綠燈]
        XCTAssertEqual(KKRoute(urlString: "kkbox://playctrl_pause"), .pause)
    }
}

真正解放 Swift enum 的威力在這個下面這個例子:

// KKRoute.swift
enum KKRoute {
    case pause
    case viewAlbum(albumID: String)
    case unknown

    init(urlString: String) {
        if urlString == "kkbox://playctrl_pause" {
            self = .pause
            return
        }

        if urlString.hasPrefix("kkbox://album/") {
            let albumID = urlString.substring(from: "kkbox://album/".endIndex)
            self = .viewAlbum(albumID: albumID)
            return
        }

        self = .unknown
    }
}

extension KKRoute: Equatable {
    public static func ==(lhs: KKRoute, rhs: KKRoute) -> Bool {
        switch (lhs, rhs) {
        case (let .viewAlbum(albumID1), let .viewAlbum(albumID2)):
            return albumID1 == albumID2
        case (.pause, .pause):
            return true
        case (.unknown, .unknown):
          return true
      default: 
        return false
        }
    }
}

// KKRouteTests.swift
class KKRouteTests: XCTestCase {    
    func testPause() {
        XCTAssertEqual(KKRoute(urlString: "kkbox://playctrl_pause"), .pause)
    }

    func testViewAlbum() {
        XCTAssertEqual(KKRoute(urlString: "kkbox://album/3345678"), .viewAlbum(albumID:"3345678"))
    }
}

接著把所有 schemes 用 TDD 的方式一個一個補上,就能確保 parse 過程中沒有任何錯誤。

URL Handler

這部分也很類似 Parser 的做法,只不過輸入是 KKRoute 而輸出是真正的 UI 畫面。

KKRoute.viewAlbum(albumID: "3345678") 來說,我們可以預期當 KKBOX 收到這個指令會把頁面導到 "3345678" 這個 ID 的專輯內頁。

詳細的做法跟 Parser 的驗證方法一樣,在這就不贅述。

後記

其實這一篇字字血淚呀,在執行過程中才發現我們手上沒有所有的測試案例,就連 QA 也沒有。 在這兩週重構的過程中,我把所有 schemes 搞懂是在做什麼的以外,也建立了所有測試案例(昏倒..

Reference

iOS 軟體開發

實作 UI Stack – 簡化邏輯讓 UI 不再出錯

你是否也曾因為一在發生的 UI 錯誤煩惱? 當 QA 回報某個頁面錯誤,[Bug] 精選頁面 Load more 未顯示 Loading indicator,為什麼要說又呢?

如果你也有這樣的症頭,請繼續看下去。

為什麼要說又呢

雖然我們依循 Model-View-Controller(MVC) 的架構開發,但對於複雜的 UI 狀態改變還是會出錯。為什麼?

回顧原先我們是如何管理複雜的狀態:

Class KKFeaturedViewController: UIViewController {
    var cards: [Card]
    var isLoading: Bool
    var isReloading: Bool
    var hasMore: Bool
    // ...
}

沒錯,我們最初建立很多個 flag 來表示狀態,乍看之下沒什麼問題。讓我們回到 QA 回報的問題 [Bug] 精選頁面 Load more 未顯示 Loading indicator ,可以從上面的變數中推測當 Load more 時,會是什麼狀態?

  1. cards.count > 0
  2. isLoading == true
  3. isReloading == false
  4. hasMore == false || true

由下列程式碼中很清楚的光是 cards.counthasMore 就定義了四種狀態。更不用說在此架構下若要滴水不漏,你需要處理 2 的 4 次方,也就是 16 種狀態。

Class KKFeaturedViewController: UIViewController {
    // ...

    func fetchCardAPI() {
        // ...
        isLoading = true
        if hasMore {
            if cards.count == 0 {
                // First load without content (reload)
            }
            else {
                // Loading more with content (load more)
            }
        }
        else {
            if cards.count == 0 {
                // Empty content
            }
            else {
                // Content complete
            }
        }
    }
}

其實有些狀態組合並不存在。

若 16 個狀態組合中出現了不該出現的狀態,Bug 就產生了。而且這樣的程式碼不容易分辨哪些狀態有處理過,哪些沒有,像是複雜的電路板到處接來接去,出錯時不容易找到問題出在哪,當然也就不容易修正。

複雜的程式碼流程就像亂接的電路板

我想有限狀態機 可以幫我解決這個問題。有限狀態機的概念是 在有線個數的狀態裡,狀態之間轉移的數學模型。 主要的元素有 狀態, 動作(事件) 來延伸出 狀態A + 事件 => 狀態B 。最常見的應用是交通燈(紅綠燈),也就是 綠燈時 + 60秒 => 黃燈 以此類推。

回過頭來整理真正會出現的狀態,原本 16 種狀態只有成 8 種會出現,一半以上的狀態都可以忽略 (難怪這麼多 Bug )

參考How to fix a bad user interface一文中並實作其 UI Stack 的概念。

UI Stack

狀態實作

在我們情境下需要考慮到較多的狀態,所以就從原本的 5 種延伸成下列 8 種(請你視使用情境而自行調整成需要的狀態)。

將 ViewController 的權責從 處理狀態轉換 + 過濾不必要的狀態 + 各個子UI管理 被簡化成 根據狀態顯示UI,並且集中管理。

enum UIStack {
    // Blank State
    case initial // 初始狀態尚未開始載入資料
    // Loading State
    case initialLoading // 初始載入,可與 Reload 共用
    case partialWithLoading // 載入部分資料,但又觸發載入下一頁
    // Partial State
    case partial //載入部分資料
    case partialWithError //載入部分資料,但載入新資料過程有誤
    // Error State
    case error // 錯誤
    // Ideal State
    case perfect // 資料完整載入
    case empty // 打過 API 但是沒有資料 (empty inbox 的概念)
}

Class ListViewController: UIViewController {
    internal var state: UIStack = .initial {
        didSet {
            // * switch cases ... * //
        }
    }
}

總結

設定狀態顯示UI 的邏輯分離後會讓事情簡單很多,防止自己粗心大意。

開發加速

開發過程中可隨時模擬狀態,就連部分載入但有錯誤的情境都可輕易重現。
舉例來說,想要重現 contentEmpty 這個狀態,在以前我可能要去修改 API Clint 去偽裝這隻 API 回應給我 0 個資料,而現在只要直接改 state 即可。

容易除錯

當 UI 出錯時就可以分成兩種錯誤,一種是狀態錯了,另一種是對應的 UI 錯了。

程式碼集中

跟狀態相關的程式碼會集中在一個地方,統一實作。

防呆

狀態一但被定義,Swift 的 Switch case 會強迫你要實作所有 cases,不讓我們有偷懶的機會。

Reference