Browsing Category

軟體開發

技術債 軟體開發

💸還技術債的藝術 II – 一魚多吃,除技術債同時提升效能

本篇文章是 💸還技術債的藝術 I 的續集,沒看過的觀眾建議先連過去看看。

漫無目的從 Code base 找技術債並不是個聰明的作法,雖然這麼做你一定能馬上找到數十個技術債的嫌疑犯,但很可能他們都是清白的,就像是在路上隨機抓人就懷疑他犯法一樣,我不會說這個方法完全無效,只是它相對會花上更多時間。

怎麼做才是有效率的呢?

這個時機很簡單,你開發新功能的時候。

當你開發新功能時多多少少會接觸到原有的程式碼,為了要使用它們你會更仔細去審視實作細節,此時你很可能會發現原有程式碼的缺陷,但這個時候請先不要急著解決它,因為你現在的首要任務是完成新功能的開發,先把它們放到待辦事項中,等功能做一個段落時提出來與團隊成員討論,並隨著開發時程的步調來調整決定還哪個債及何時還債。

如果每遇到一個技術債就開支線任務的話你會遇到許多問題:

大量的 Context switching

這對生產力非常有害,人腦跟電腦不一樣的地方是我們每次 Context swtich 都會耗費巨量的能量,而每個人每天的注意力能量是非常有限的。相信你也過這樣的經驗,當你同處理多件事情時很容易覺得累,每次回到另一個 Context 都會覺得『我剛剛處理到哪了?』

重複的解決相同的問題

假如你總共遇到三個相似的技術債,但因為你的方法是來一個打一個,因此會犧牲了有可能批次處理的機會,相當可惜。

解決不了的技術債

也許這個技術債是 NP 問題,一時之間是無法解決的,不能解決頂多就是浪費了時間去試,怕就怕你以為你解決了,但其實產生了的未知問題,更糟的情況是你為此耽誤太多時間而延誤新功能開發時程。

你沒解決技術債,也沒完成功能開發,開了太多支線任務,導致你主線任務根本破不完或是熬夜才能破完,你不會想要落此下場的。

實際案例

故事是這樣的,我在開發某個跟會員相關的功能過程中發現一個問題,因為電話號碼在會員系統中有唯一性,也就是不會有同兩個人擁有同一個電話號碼,所以在新增(修改)會員電話時必須做檢查,確認該電話在系統中還沒被使用,才能讓這個修改成立。

這個需求簡單明瞭但舊有的程式碼卻大有問題,其資料結構是把所有會員資料存在一個 Array 當中進行操作。

也就是說,當進行電話號碼欄位比對時會循序檢查 Array 中的每一筆會員的電話號碼是否與目標值相同,全部會員資料有多少筆就比對多少次,這是一個沒有效率的實作方法。而其方法同時也影響了「查找此會員是否存在」,或是「其電話號碼是否已被使用」的功能,以演算法的術語來說就是時間複雜度是 O(N)

不僅如此,也因為把會員資料全部倒入記憶體中佔據大量空間,而大多時候它們是閒置的資料,只有在操作會員相關資訊時才有作用,而造成無謂的記憶體浪費。

這個誤用的資料結構造成了「搜尋效能低」及「濫用記憶體」的問題。

由於隨著該功能上線,我們預期將會大大的提升各餐廳的會員數量,進而將放大其問題的嚴重性。這個技術債會隨著公司的 Business scale 升級而被放大,而現在正是還它的好時機。

在幾次 Stand up 會議討論後主管也認為該問題很嚴重值得花時間解決。

技術債考古

做任何決定前,先確保擁有足夠多的資訊來輔助判斷,避免在資訊不足的情況下做出錯誤決定。

於是我向同事請教當時的時空背景,先前之所以把所有資料放入 Array 之中是當時常常遇到資料庫毀損,在找不到原因的情況下先加了一層快取來避免直接對資料庫操作。

「不過現在我們很少資料庫毀損了,這段應該可以改。」他說。

衡量解決方案

在做之前先衡量有哪些解決方案,並衡量其優缺點、實作難度、你所擁有的時間等維度來選擇最佳的解決方案。

方案一:建立 HashMap 加速搜尋

於是很直覺的想把 Array 改成 HashMap ,但這麼做的缺點很明顯,需要維護多個 HashMap,像是 UUID to 會員,電話 to 會員等等。而且這麼做並沒有解決記憶體使用量的問題,反而有可能上升。

*實作複雜度較高,且問題只解決一半。

方案二:回歸 SQL 正途

後來想也許該用更簡單的方式,也許根本不需要 Cache,直接用資料庫現 SQL query 來實作電話比對,但我不確定自己是否夠熟悉 SQL。(因為還有另一個技術債,我們把會員的資料是壓成 JSON 字串,直接存入某個欄位,藉此來設計一種通用的 Table)

*實作複雜度較低,問題有機會可以一次解決。
*不確定因素是:對 SQL 不熟

基於「不要過早最佳化」及「不要過度設計」的想法,再加上方案二才能同時加快搜尋以及減少記憶體使用量,於是答案就很明顯了。

值得一提的是這裡請你以結果導向來做決策,無論實作方法的困難度最終我們在乎的是成果。很多時候我們會落入過度設計的陷阱裡,使用了多複雜的設計最後發現別人看不懂難以維護,這樣一來反而本末倒置。

衡量成果

有了初步的假設與解決方案,接著要來試著驗證它們。

如果少了這個步驟,將難以確認修改到底改善多少?值不值得我們花時間修改?於是簡單做了個實驗來看看修改前後的差異。

搜尋時間

在 200k 筆會員資料的情況下,用原方法確認「單筆電話號碼是否被使用」的時間是 2.05 秒

原方法耗時 2.05 秒

在同樣會員數情境下,新的方法只需要 0.051 秒

新方法耗時 0.051 秒

在效能方面結果相當顯著,該方法從 2 秒下降到 0.05 秒。
效能提升大約 40 倍。
記憶體使用量
在會員數 28k 的情境下最高記憶體使用量從 450MB 降到 344MB 。
大約降低 100MB

記憶體使用量差異示意圖

結語

以前我也是看到技術債就馬上動手去修改,恨不得把房間整理的一塵不染。

但我後來了解時間是有限的,而程式碼改善是無限的。隨著時間的流逝你永遠可以找到更好的方式還債(又或者不用還)。永遠有新出的 framework 或者是 Open source 幫你解決了過去某個問題。

用聰明的方法還債,將會使你工作輕鬆很多,也對公司有更多貢獻。
用健康的心態去面對技術債,會讓你的人生好過很多。

把心思與力氣放在最重要的事情上,才能在有限的人生裡發揮最大效益。

軟體開發

💸還技術債的藝術 I – 消除對技術債的傲慢與偏見

你是否也有接手過別人程式碼的經驗?

無論是加入新的專案或是工作環境,我們都有很大的機會承接這些遺產。作為一個新進成員,在閱讀舊有程式碼時難免會有許多疑問。

  • 為什麼這裡會這樣寫?
  • 為什麼這裡會需要這樣的 Workaround?
  • 為什麼這裡要這樣設計?
  • 為什麼會寫的這麼差?

『全部重寫好了?!』 難免會這樣想。

為什麼會有技術債?

請先等一等,思考為什麼這些技術債存在?也許是下列幾種原因:
– 時辰壓力:為追趕產品上線時程,不夠時間考慮周全
– 需求變更:過去符合需求但不符合新的需求
– 技術演進:新的程式碼語法只要一行就能解決

如果從一間企業的成長歷程中去看技術債這個議題,你會發現在草創時機不太會請到厲害的工程師加盟。

為什麼這樣說呢?

從軟體工程師的角度來看

以成長面來看,新創公司變動很快,通常是邊摸索需求邊開發功能,而團隊中很可能只有你一位 iOS 工程師,在孤軍奮戰加戰況危急的情況下你很難有時間好好累積良好的開發知識與經驗。

從風險與報酬面來看,參加新創的風險很高,但報酬率不一定高,也許先加盟較穩定的企業,等新創公司的價值被證明再帶刀投靠也不遲。

從公司的角度來看

當商業模式還未被證明可行,又有資金壓力的前提下,創業家必須要在花多少成本來請多厲害的人這兩件事情上取得平衡。

如果要請 A 咖勢必要付出比較多資金成本,而且人家也不一定會願意加盟。
如果請 C 咖資金成本比較低,但當然技術債會欠得多。

為了讓公司存活必須要積極的驗證市場,因此需求劇烈變動是可預期的,而在需求頻繁修改的情況下,幾乎不可能設計出好的架構,因為設計好的架構也是需要時間跟經驗。

綜合上述兩個角度來看,創業初期就有超棒的軟體架構不是不可能,但不是活下去的必要條件。

技術債是必然的產物,有程式碼就有技術債。

不需要還的技術債

公司開了 3 個產品線,為了快速驗證市場需求同時兼顧公司營運成本,一年後會只留下賺錢的產品。在這個情況下,你會秉持職人心態每行程式碼斤斤計較而拖慢產品進程,還是快速開發適時留下技術債?

現實是當產品下線時,程式碼再漂亮架構再好都是沒有意義的。

你要先活著才能還債。
又或者說,不活著就不用還債。

講到這你已清楚技術債的生長模式,要處理它就不會這麼難受。

更健康的心態去面對技術債

想像你是一位籃球選手,在比賽下半場教練把你換上場,此時球隊輸 20 分,你會覺得?
1. 輸 20 分根本追不回來,派我上來幹嘛?
2. 覺得球隊就靠我了,把球都給我單打
3. 能追多少算多少,一球一球打好來

最壞的時代是最好的機會

你也可以想這是證明自己能力的好機會,除了幫助公司解決當前的難題,自己也能從中獲得成就感,當然也有使用者因此而受惠等等的好處。

而工作這回事就是公司花錢交換你來解決問題。

選擇值得還的技術債

綜觀整個 Code base ,需要還的債滿坑滿谷,究竟該先處理哪一個?
在這提供兩個考量的指標「還債的效益」,以及「還債的困難度」。

還債的效益

學貸,房貸,信用卡債,先還哪個?

房貸跟學貸的利率比較低的,而信用卡債則是相對高。也就是說在同樣的時間,要付更多利息在信用卡債上,因此優先償還信用卡債是比較聰明的選擇。

醫院急診室先救誰?

突然間有很多病人湧進急診室,有些病患的問題比較緊急,10 分鐘內不處理可能會死亡,有些病患相對不危及性命,面臨這樣的問題應該怎麼選擇?

作為醫生的救援原則就是先避免死亡數字上升,再服務其他病人。

從上述兩個案子可以歸納出處理順序的重要性,債不是先來的先還,債也不是最大的先還,注意順序可以幫助你相對輕鬆的償還債務。

還債的困難度

假如有2個病患危及性命,其中有一個手術難度較高,需要全身麻醉的大型手術,且其成功率僅30%。

另一個病患同樣也是性命垂危,僅需要小手術即可,其手術成功率為 90%。
只能先救一個,你會怎麼選擇?

大範圍的重構或重寫,難度越高且越有可能造成其他問題,從上面的例子可以看出來,在同樣效益前提下,相對較小較容易的任務代表花費時間越短,總體效益會更高。

未完待續

技術債是一個有趣的議題,只要你還有在寫程式就很有可能遇到,不論你加入的是 Facebook, Google 等世界級的軟體公司,還是一些小型軟體公司服務都避不開它。

現在用的最新技術,也會隨著時間過去日換星移而被人們當成技術債。

技術:『想當初你是這麼愛我的不是嗎?』

下集💸還技術債的藝術II會以實際案例跟大家分享我們有哪些技術債,我們怎麼去選擇優先還哪個,還有在面對抉擇時我們怎麼權衡。

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

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

軟體開發

蘋果 Go to fail

即便是蘋果也犯過低其錯誤
Anatomy of a “goto fail” – Apple’s SSL bug explained, plus an unofficial patch for OS X!

hashOut.data = hashes + SSL_MD5_DIGEST_LEN;
hashOut.length = SSL_SHA1_DIGEST_LEN;
if ((err = SSLFreeBuffer(&hashCtx)) != 0)
    goto fail;
if ((err = ReadyHash(&SSLHashSHA1, &hashCtx)) != 0)
    goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &clientRandom)) != 0)
    goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0)
    goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
    goto fail;
    goto fail;  /* MISTAKE! THIS LINE SHOULD NOT BE HERE */
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
    goto fail;

err = sslRawVerify(...);

以此為鑑,任何的 if condition 我都會用 {} 包起來。

軟體開發

重構:消除隱藏的依賴關係

不可測試的程式碼就像是沒打掃的房間,可能玩具散落一地,又或是不穿的衣服堆得亂七八糟。

想像你的每一個類別(Class)就是房間,可能有的很小容易打掃,有的很大不容易保持乾淨。隨著你的產品(Project)房子愈來愈大,房間愈來愈多,要整理乾淨就愈難。很多人甚至就不打掃放任專案慢慢發臭,輕微一點導致
bug 開趴,嚴重一點則導致開發新功能困難舉步維艱。

今天讓我們來看看一個例子 HBBeePointUtilites,它是一個用來讀取點數暫存的物件,但因為所有 method 都是實作成 class
method,所以讓這段程式碼難以測試,在這邊將示範如何用依賴注入(Dependency Injection)
隱藏的依賴關係轉成顯著的依賴關係,藉此讓這段程式碼得以測試。

隱藏在實作的外部依賴

先來看看我們怎麼使用這個物件:

查看它的實作會發現這個物件有個外部依賴 UserDefaults.standard ,糟糕的是他是一個 singleton
,在不使用其他外部測試 framework 的情況下,我們無法換掉它,也就無法測試。

從上面這段程式碼來看,你大概可以臆測到它是為了在專案全域都可以讀取點數才這樣設計,但是這樣的設計卻犧牲了程式碼的可測試性,在這樣的前提之下我們可以如何修改呢?

先設定目標:
1. 全域存取
2. 可測試

在不破壞原功能的前提下,為這個物件增加可測試性。

獨生子模式 Singleton pattern

針對 目標1: 全域存取 其實不只是 class method 可以, singleton 也做的到:

如上面這段程式碼所示,兩段程式碼只差在.shared ,只不過一個是用 class method 另一個則是 singleton ,很容易相互取代。

建構子依賴注入 DI

目標2: 可測試 比較複雜,我們可以透過依賴注入將原先隱藏在其中的依賴UserDefaults.standard 暴露至建構子當中。

接著我們開一個 property userDefault 將原本直接使用 UserDefaults.standard 轉向在建構時對外要求一個
UserDefaults

這時候你已經可以測試了

// Arrange
let fakeUserDefaults = UserDefaults(suiteName: “Test”)! fakeUserDefaults.set(1000, forKey: “BeePoints”)
let util = HBBeePointUtilites(userDefaults: fakeUserDefaults)

// Action
let result = util.getPoint() // 1000

// Assert
XCTAssert(result == 1000) // true

確定程式碼得以測試以後,才將原本的 class method 移除,最後程式碼會是這樣:

一次做一件事

需要注意的是,一次替換太多 class method 可能導致出錯,萬一手殘就會前功盡棄。所以請一次替換一個 class
method
,並且每一次完成替換前加入測試,完成後才提交 commit 。

值得一提的是 singleton 這邊的小技巧,透過依賴注入但是預設注入 UserDefaults.standard:

這樣在 Production 程式碼可以維持乾淨,像下面的程式碼這樣:

太好了,現在這個物件可測試,我們對產品的信心又提升了一點!

讓我們回顧一下步驟:

  1. 用建構子依賴注入暴露原本隱藏的依賴
  2. 用 singleton 模式讓方法全域存取
  3. 複製 class method 為 instance method
  4. 為新的 method 加入測試
  5. 移除舊的 class method

(重複 3–5 步驟直到所有 method 都可測試,並且一次只換一個 method)

結論

若實作時沒考慮到單元測試,我們理所當然的會設計出不可測試的程式碼。但是已經髒亂的房間不代表可以不管,讓我們一起捲起袖子來大掃除。

在這次簡單的例子中我想大家都能深刻瞭解,若是平常不好好打掃,最後大掃除就很累 XD

所以,寫新的程式碼時請將可測試性考慮進去,因為愈早考慮可測試性,事情就越簡單。保持房間乾淨整齊,這樣下一個來使用的人(極有可能是3個月後的自己)才會容易根據需求更改房間擺設。

我是 honestbee 的 iOS 工程師 Liyao Chen 我們下次見!

Xcode

顯示 Xcode build 時間

在 Terminal 輸入

defaults write com.apple.dt.Xcode ShowBuildOperationDuration -bool YES

重開 Xcode 之後就會顯示 Build time

Xcode build time

Reference

  • http://tonyarnold.com/2016/04/20/xcode-build-duration.html
軟體開發

我在KKTOWN的小故事 – 省下一個月開發時間

雖然 KKTOWN 最終落幕了,但從中學到很多經驗是珍貴的,如果不記下來不是太可惜了嗎?


KKTOWN 第一個物流系統商是全家,在系統順利銜接後,我們向全家提出一些行銷的想法,當使用者用 KKTOWN 到全家寄貨,全家會送一杯免費咖啡給他。

怎麼送?

我們 PM 設計整個流程與完整功能,預告接下來的一個月以這個行銷活動為主,連 Wireframe 都畫好了。

我問說:『這樣的功能我們會用到幾次? 是不是跟全家談好多次合作了? 或者未來我們會跟 7-11 有相同的合作?』

PM:「不會,只用這一次。」

我:『只用一次我們要花全部人一個月的時間? 就為了一檔活動? 沒有更好的方法嗎?』

PM:「…」

會議氣氛僵持不下,但我還是不想做這種高成本低效益的事情。

幾分鐘後我說:『我們系統會知道哪些使用者用全家寄貨,同時又知道該使用者的電話號碼,可以用簡訊寄送兌換碼如何?』同時我的眼光轉向後端同事

後端同事:「可以。」

PM:「好,那我們就用簡訊。但是 Banner 的功能還是要做。」

我:『沒問題。』

草創期任何資源都很寶貴,尤其是工程資源,我們應該 Always 試著找出更聰明的方法做事。

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