顯示具有 Test 標籤的文章。 顯示所有文章
顯示具有 Test 標籤的文章。 顯示所有文章

2015年2月5日 星期四

【敏捷迭代式開發中的測試】第二篇:夥伴.品質.溝通


從前一篇文章中:『【敏捷迭代式開發中的測試】第一篇:有關測試的真實劇本序章』提及了測試人員在敏捷開發流程中所扮演的角色及人員素質。在這一篇文章裡,將進一步細化說明測試人員的關鍵工作成功因素及工作目標。

一、夥伴意識

相較於瀑布式開發中,將測試列為開發後的工作環節之一,在迭代式開發中測試人員的角色顯示與開發與需求方都更為親暱地多。

瀑布式開發將「需求」發展至「產出物」的一系列工作事項,以功能區分及線性流程串起來。需求蒐集及分析、系統分析、系統設計、系統開發、系統測試,及至驗收,都可能是以一個實體部門進行負責。完美的情形下,每個環節的交付物是如鎖練般地被串接,也或者像是線上地圖系統可以從整個地球的五大洲圖逐一細化到指定國定、城市、鄉鎮、道路、巷弄一樣的能不斷的 refine。

當然,這種完美並不存在,所以對於每個環節的部門或人員來說,上游的產出物或導致本身環節的產出物不正確、與需求目標產生偏離、或是根本無法產出。除非,在這個環節去償還上一個環節所欠的債!分析的債、設計的債、溝通的債、文件的債…債的類型很多,共同之處在於:還債的人大多和欠債的人不一樣。

還債行為會導致組織內的夥伴意識崩潰。因為欠債的人有時甚至不知道欠下債的人是自己,還進一步地怪罪幫忙還債的人為什麼無法如預期地進行產出。如果下游將欠債的事實告知上游,就會變會上游必須暫停手邊的工作、去償還自己積欠的債務,而此時就代表:在該時間點,上游原先預定的工作將產生延遲,而下游突然間變得沒事做。

在這種總體時間及產出物於各環節中不平衡的狀況下,責任的歸屬問題、專案成敗關鍵的茅頭就會很人性地讓整個組織容易處於敵對、相互隔離、彼此試探…等內耗。那循環儼然是不良的、參雜了很多人性及政治因素,一旦與風氣、權力個人相結合後,就會成為組織中極為難解的問題。

迭代式開發,更仔細地說,是「重複漸增」式的系統演化方式,要求的是測試人員成為開發人員的夥伴。當開發人員專注於功能的構建時,測試人員使用外部環視的角度為其提示品質相關的問題,讓開發的進展與問題的浮現腳步一致。這樣的好處是:問題在剛開始產生時,多半很容易修正!而測試人員應該是開發人員的另一雙眼鏡,讓開發人員隨時能輕易地改正自己犯下的錯誤、沒正視到的盲點。

也由於測試人員能隨時協助開發人員關注品質,所以開發人員能更專注、更放心地把心力放在功能的實踐上,而不需自己三不五時地必須去確認、質疑某些質量需求、思考是否要對現有的設計想法進行修正。

記得,測試人員應該和開發人員是好夥伴,就像左右手一樣,應該能共同地把好東西做出來!

二、品質

相較於專注於如何把真正的需求進行蒐集、分析、系統化、及系統分析、設計及整合的人員來說,測試員會幾近參與上述所有流程,因為他們需要做整體的檢視,並且條列出符合環境限制及真實需求的品質定義才行。在執行層面,不論他們做了什麼,都是為了維護該品質定義清單中的每一項。

測試人員在品質這一項工作上的探究,基本上和架構師的工作是有層次相關的。首先,除了要留意 context limits (這包括客戶及自身組織)及功能性需求 (functional requirement)外,品質的另一個名字還可以稱之為「非功能性需求」(non-functional requirement)。

依需求/產出物的要求、不同行業別、組織限制的情形下,所需留意的非功能性需求有很大的不同。但是大抵來說,要有以下起碼的認知:非功能性需求的各項是經常彼此產生矛盾、需要權衡的。例如:易用性和安全性。要有什麼樣的取捨?這可能由客戶決定、依環境限制進行妥協,或是由架構師視系統的長遠目標進行權衡。但是,真正在整個流程中不斷關注非功能性需求決議、並保持該決議沒有被違背的人,就是測試人員。

有關非功能需求有哪些?筆者偷個懶,給個 wiki 的定義,讓讀者去發揮吧!

http://zh.wikipedia.org/wiki/非功能性需求


三、溝通

當我們發現測試人員,依上述所言,需要:

1. 全程參與需求至產出的所有流程。
2. 需要瞭解客戶、架構師對品質的主要訴求、取捨以及環境上有哪些限制?
3. 必須和開發人員 (乃至上述的所有角色) 都要能成為良佳的夥伴關係。

那麼,測試人員在溝通上的能力就應該備受重視!

測試人員要能和開發人員進行:

  1. 有效的溝通:提供明確的輸入及輸出限制及回應品質。
  2. 加分的溝通:隨時讓開發人員了解其忽略或沒留意到的地方,讓開發人員對整體需求有更具體的方向感。
  3. 體貼的溝通:能理解開發人員的難處,並將其反應在品質控管的結果中。
測試人員要能和需求方協議出合理的品質要求,就必須進行:
  1. 務實的溝通:環境的限制、技術的範圍、資源的有限性都是必須務實的原因。當其他人過於關注需求的想望與功能的實現時,測試人員要能即時提出務實的中斷點,讓全場的人得以回到地面上再進行討論。
  2. 雙贏的溝通:由於測試人員能站在務實的角度去觀察系統整體的進展,所以也有提出讓系統需求及開發方創造雙贏的餘裕及職責。
綜合上述,測試人員在溝通上的能力及個性或其它天賦上的要求是不低的。你身邊已經有一個好夥伴了嗎?那麼,就恭禧你了!




2015年1月14日 星期三

【敏捷迭代式開發中的測試】第一篇:有關測試的真實劇本序章。

定義測試 & 測試人員特質

軟體測試的目的,是找出其實際行為及期待結果之間的差異。 
(定義來源:Auerbach Publications, 2008, 《Software Testing and Continuous Quality Improvement, Third Edition》)

一般來說,所謂的「差異」指的是較負面的「缺陷」;不過若積極一點想,也應該包括較正面的「驚喜」。以這個定義來看,可以想像測試就像是在比對兩張重疊的描圖紙上的同一幅圖畫上的線條般,透著光、瞄著眼,指出這裡有出入、那邊要修正…之類細瑣繁複的工作。

畫有同一幅圖畫、重疊在一起的兩張描圖紙,其中一張是所謂的「原稿」;另一張就是「描稿」。假設原稿上的圖畫,其線條清晰俐落、運筆傳神,在工具足夠的情形下,例如:有舒適的座位、燈線充足的透寫台、粗細相同的繪筆,那麼理論上可以讓描稿趨近於原稿的難易度應該不會太高。這樣的工作也相對顯得單調、不需要創造性思維,只需要足夠的耐心及細心。

但是,現實世界裡會發生的事,真的有那麼簡單嗎?或說,有那麼單調嗎?

在軟體的世界裡,原稿從來都不會太清晰,甚至原稿大多還不是作者畫的!作者只提供異常抽象的概念、世界大同般的期望。如果作者是台灣風的,還可能加上想殺人滅口的成本要求。所謂成本,可能是時間、金錢或品質。

那麼誰來畫原稿呢?應該是畫家吧?很不幸,通常也不是。通常原作者就是最不懂得畫畫方法的那個人。而畫原稿的人,可能很懂得藝術史、對於名畫的歷史背景如數家珍、對於名畫的價值也很瞭解。但是,就像古典音樂聽再多次、再熟的人也無法進行任何一種樂器的演奏般,那負責畫原稿的人,通常畫技水平可能和他最後一次交中學時代美術作業的程度差不了多少。但是他長大了,經過多年看畫的經驗,他可能以為自己能以炭筆畫出噴畫效果了?或是認為灰階鉛筆畫就能充分呈現色彩氛圍?

所以實際產出的原稿到底和原作者想像的原稿,是否一致?很不幸的,通常就算可以審核,也經常性地被忽略。因為,真正的產出是「描稿」。沒人想在「原稿」上浪費時間。

在軟體的世界裡這並沒有任何值得大驚小怪的地方,因為就軟體而言,若「原稿」畫得是飛機,那麼「描稿」就是一架應該真的可以飛的飛機。

所以,現實世界裡的軟體測試,沒那麼簡單、沒那麼單調。因為「原稿」可能和作者的期望非常不一致,畫「原稿」的人的技法可能非常粗略、粗略到無法真實反應原作者的期望:即使那期望已經說的夠明白了。

「描稿」的人,一般來說,是真的畫匠。有些畫匠會自行想像原稿不清楚、不合理的部分,有些畫匠會把模糊、無法想像的部分當作什麼都沒畫上去,就直接留白下來。哪種風格比較好呢?答案在原作者身上:剛好符合的就好,不符合及根本沒畫上去的,一定不好。

所以應該自行想像,然後外加運氣是比較好的組合方式嗎?不是,當然不是。如果蓋的是一棟101大樓或是大型客機,誰敢賭這種運氣呢?沒有,我想幾乎沒有。

以往,我們會在「描稿」出圖後,找一位「審稿」的人,由對方來挑三揀四、說明哪邊的線條曲度不足、哪裡的勾勤又不夠有力。這樣的「審稿者」通常和「描稿者」不會交情太好。因為兩者經常會主觀地自行詮釋某些線條該有的樣子,而忘記去問原作者到底要什麼樣的線條?或者,「審稿者」無法具體描述某線條到底哪裡不好?只反應:怪怪的、我想不是這樣、就是缺少了點什麼。「描稿者」無所適所,可能覺得對方只是在找麻煩。

到了敏捷迭代式開發的流程裡,「審稿者」在心理上、工作內容上都應該變得幾乎完全不同。原作者的期望是什麼?和「原稿」符合程度能多少?如果提出疑問,讓該問題的答案能引導「原稿」更接近原作者的期望,變成「審稿者」的工作之一。

「原稿」和「描稿」之間的差異有哪些?該如何追查出?差異量是否能被作者所接受?這也是「審稿者」的工作了。所以「審稿者」的工作,並不是「描稿」出現後才被啟動,而是打從作者開始描述「原稿」時就開始了。「審稿者」要能輔助讓「原稿」更接近「期望」、讓「描稿」與「原稿」之間的差異在壞的方面降至最少、在好的方面被突顯。

為了達成上面的目的,「審稿者」必須參與至整個圖畫描製的整個流程、與作者、原稿畫者、描稿畫者都充份合作才行!非常不簡單!非常不單調!

回到軟體開發的故事,審稿者就是測試人員。他負責與作者(客戶)、原稿畫者(PM、分析師、架構師)、描畫者(開發單位)進行協作,其工作內容就是最終產出物與客戶期待結果之間的品質管理。

測試人員要做好以上事項,很明顯地:必須善長思考、溝通、對高質量的挑剔、善於學習並懂得團隊合作的務實者。工作內容絕對不比任何角色來得輕、人格特性更是得要有點難能可貴才行。

測試人員是專案成員中最關注品質的成員之一 (另一者為架構師),他們是專案要順利進行的有力盟軍,絕對不是以往那種在交付前負責找麻煩就可以的角色。


2014年4月14日 星期一

重構教程

重構教程


程式語言:Objective-C
IDE: Xcode 5

有關重構範例:
[來源] IOS7QRCodeDemo
[功能說明] 使用 iOS7 AVFoundation framework 編寫 QRCode 讀取功能。
[特色]不需依賴其他第三方框架

重構理由:
1. 可讀性/可維護性
2. 延展性 (封裝性/模組化能力/置換與轉換能力)
3. 安全性
4. 最佳化能力

重構步驟:

步驟一:將原始碼專案目錄建立 SCM Repository。這裡使用的是 Git。
理由:以利還原及追蹤。這是重要的第一步。

> cd ${QR Codes} 目錄
> git init .

步驟二:審視並調整群組結構
理由:更好的可讀性,加速閱讀及查找速度。

說明:
1. 和主流程相關的部份,我收納在 Main 群組,並以建議閱讀的順序排列。
2. 把 View 和 Model 分開收放。
3. 群組之間的順序也是以建議閱讀的順序排列。
4. 群組的分類方式,可依循的規範參考有以下數種選擇,我們可以擇一或混合使用:
  (1) 依照團隊慣例 (coding guideline)
  (2) 參考泛用性高的框架 (ex: Ruby on Rails)
  (3) 參考建置工具的標準建議 (ex: Maven)


步驟三:審視資訊隱藏
理由:沒必要放在 .h 檔中變成 public 資訊的程式內容,我把它們移至 .m 檔中。
(紅色部分表示「刪除」,綠色部分表示「加入」,下圖表示:將部份程式碼從 ViewController.h 移至 ViewController.m )


技巧:只要註解掉任一行宣告,IDE沒有報錯的話,就代表其沒有任何外部程式碼需要參考它,當然也就能放心進行隱藏。重點是:別做沒必要的曝光。此項技巧是我們使用 IDE 的重要理由之一。


步驟四:重構 _previewLayer.frame = _previewView.bounds

理由:這行程式碼的用意是讓 _previewLayer 的尺寸相等於 _previewView,我打算讓它能被更快看懂。例如,我希望重構結果是這樣:

[self makeSameSize:_previewView resizeView:_previewLayer];

說明:
1. 由於原式是無法進行方法抽取的,所以要分幾個程式進行重構。第一招是:加入區域變數!(紅色部份是原式,我改寫成綠色部份。然後編譯、測試。)


2.  然後進行方法的抽出。


3. 然後再把區域變數 inline 回去。


4. 可以看出來:42~43行的兩個區域變數已經不需要了,就拿掉它吧。



步驟五:協調單一函式內敘句的質量密度


理由:
1. 39, 40 行都是訊息傳送/方法呼叫,但是 42 ~ 53 行的程式碼是述句,在物理結構上就不一致。
2. 由上圖上紅色圓角矩形所示,viewDidLoad 內總共做了5件事,其中3件事是以述句的組合構成。若是把5件事都以同樣的密度陳述,用更直覺得方式表達會更好。

說明:
1. 首先,先把第3、第4件事,用抽出方法進行重構,結果如下:


先刻意留下第 61 行的第5件事,只把第 55 行及第 57 行的第3、第4件事抽出來後,停在這兒說明一件事。請看看第45行的註解,該註解在 registerNotificationForCameraOnOff 方法抽出後,顯示不太重要了!於是,我們可以將它進行移除。

這個動作將代出重構後程式碼的幾個象徵:
 (1) 每個 method 的行數均不多,而且每一行的執行目的,都有一個單一、難再切割的目的。
 (2) 幾乎可以拿掉大部份的註解:因為方法名稱已經足夠傳達程式碼的意圖了。

最後的結果如下:

這裡要注意部分是:為程式碼的閱讀者的習慣提出設想。

一般來說,我們對於文字的閱讀方向是:由上而下,由左至右。為了能讓程式碼的閱讀者有要順暢的閱讀體驗,把 viewDidLoad 方法提至最上方。原因很簡單:閱碼者應該會想知道 viewDidLoad 做了哪幾件事?然後視其需要,再決定要不要 drill down 讀下去。

然後,方法的呼叫順序與方法的擺放順序盡可能一致,有利於查找。

到了這個步驟完成,會發現重構過程式碼應該易讀、看起來乾淨,沒有不需要的註解(通常註解會用不同顏色區分,也會干擾閱讀感覺)。當然,美感方面有個人的差異,這點只能說重構者要 do my best。


步驟六:思考、追究並改善下列程式碼

理由:在 setupCaptureSession 方法中,有下列程式碼,

if (_captureSession)
        return;

這是什麼意思呢?更直覺的寫法應該是:

if (_captureSession!=nil)
        return;

那為什麼 setupCaptureSession 方法中的 _captureSession 何時會等於 nil 呢?
單看程式碼是找不出原因的。但是,若去思考 setupCaptureSession 的被呼叫位置 : viewDidLoad  方法內,大約就可以找到原因:是在防止出現 memory warning 發生時可能造成 _captureSession 不為 nil 而又重複執行一次 setupCaptureSession 的情況發生。

使用方法抽出,以 isMemoryWarningOccuredAndCaptureSessionCreatedAlready 命名之:

- (BOOL)isMemoryWarningOccuredAndCaptureSessionCreatedAlready
{
    return (_captureSession!=nil);
}

- (void)setupCaptureSession
{
    if ([self isMemoryWarningOccuredAndCaptureSessionCreatedAlready])
    {
        return;
    }

   //以下忽略
}

上面的程式碼中,雖然方法名字很長,不過重構者必須以假設:起碼,日後回來看程式碼的自己要能理解程式的真實動作及其意圖才行,而且長名在編譯後就會消失,所以用意圖明確的名稱絕對值得。此外,即使 if 或其他條件句中的述句只有一句,也最好完整加上大括號。在為數者眾、以及我自己團隊的 coding guideline 中,我都會加上此一要求。


步驟七:持續重構 setupCaptureSession method


可以看到第65行是很長的註解,67~72行是在偵測裝置是否擁有相機。若沒有就離開此 method。一樣,用方法抽出進行重構,抽出 isNoVideoCamera 方法:



步驟8:收攏整理 isMemoryWarningOccuredAndCaptureSessionCreatedAlready 和 isNoVideoCamera

理由:可以看出這兩個方法都是 setupCaptureSession 執行的前置條件,所以可以再進一步收攏,並整理好順序如下。

- (void)setupCaptureSession
{
    if ([self isMemoryWarningOccuredAndCaptureSessionCreatedAlready] ||
        [self isNoVideoCamera])
    {
        return;
    }
    
    _captureSession = [[AVCaptureSession alloc] init];
    
    //略
    
}

- (BOOL)isMemoryWarningOccuredAndCaptureSessionCreatedAlready
{
    return (_captureSession!=nil);
}

- (BOOL)isNoVideoCamera
{
    _videoDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
    
    if (_videoDevice == nil) {
        NSLog(@"No video camera on this device!");
        return YES;
    }
    return NO;
}


步驟9:


理由:
觀察或實驗一下第64~65行,會發現:
1. 主要是為了建立、設定 _previewLayer。
2. 建立 _previewLayer 時需要傳入 _captureSession 。

其中,「需要傳入 _captureSession 」成為這兩行寫在 setupCaptureSession 方法中的唯一理由。但是 _previewLayer 本身的建立與設定 capture session 並不是絕對需要關聯在一起的同一件事。

方法:
1. 把 64,65行進行方法抽出,並且使其呼叫順序符合需求。(在 setupCaptureSession 方法後再進行呼叫)



步驟10:質疑 running property 的必要性。

- (void)stopRunning {
    if (!_running) return;
    [_captureSession stopRunning];
    _running = NO;
}

- (void)startRunning
{
    if (_running)
        return;
    [_captureSession startRunning];
    _metadataOutput.metadataObjectTypes = _metadataOutput.availableMetadataObjectTypes;
    _running = YES;
}

理由:
在上面程式碼中, 對 running 這個 property 存在的必要性,感到質疑,因為 captureSession 有一個 instance method : isRunning 應該是相同功能的方法。在單線程運行的 App 裡,像 running 這種 flag 變數的審查不太難。

說明:
1. 註解 running property 並檢視其影響範圍。
2. 以 [_captureSession isRunning] 取代 _running 並移除多餘的程式碼
3. 測試

結果如下:

#pragma mark camera methods

- (void)stopRunning {
    if (![_captureSession isRunning])
    {
        return;
    }
    [_captureSession stopRunning];
}

- (void)startRunning
{
    if ([_captureSession isRunning])
    {
        return;
    }
    [_captureSession startRunning];
    _metadataOutput.metadataObjectTypes = _metadataOutput.availableMetadataObjectTypes;
}

嗯,應該可以再進一步重構。結果如下:

#pragma mark - camera methods

- (void)captureSessionSwitchON:(BOOL)yes
{
    if (yes && ![_captureSession isRunning]) {
        [_captureSession startRunning];
    } else if ((!yes && [_captureSession isRunning])){
        [_captureSession stopRunning];
    }
}

當然,原本 stopRunning 及 startRunning 方法的引用也要跟著修改。


步驟11:質疑雙層 enumerateObjectsUsingBlock 的必要

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection
{
    NSMutableSet *foundBarcodes = [[NSMutableSet alloc] init];
    
    [metadataObjects enumerateObjectsUsingBlock:^(AVMetadataObject *obj, NSUInteger idx, BOOL *stop)
     {
         
         [metadataObjects enumerateObjectsUsingBlock:^(AVMetadataObject *obj, NSUInteger idx, BOOL *stop) {
             NSLog(@"Metadata: %@", obj);
             if ([obj isKindOfClass:[AVMetadataMachineReadableCodeObject class]])
             {
                 AVMetadataMachineReadableCodeObject *code = (AVMetadataMachineReadableCodeObject*)[_previewLayer transformedMetadataObjectForMetadataObject:obj];
                 Barcode *barcode = [self processMetadataObject:code];
                 [foundBarcodes addObject:barcode];
             }
         }];
         
         dispatch_sync(dispatch_get_main_queue(), ^{
             ...(略)
             }];
             
             
        });
         
     }];

說明:

1. 原程式中在 captureOutput:didOutputMetadataObjects:fromConnection 中以雙層巢狀的 enumerateObjectsUsingBlock 呼叫寫成。可以嚐試把內層結構提出與外層結構處於同一層次,並觀察結果是否有異。經過實驗後,我決定把這個巢狀結構移除。

2. 然後將原本內層enumerateObjectsUsingBlock中的程式碼,以方法抽出進行重構。

- (void)findAndBuildBarcodes:(NSMutableSet *)foundBarcodes obj:(AVMetadataObject *)obj
{
    if ([obj isKindOfClass:[AVMetadataMachineReadableCodeObject class]])
    {
        AVMetadataMachineReadableCodeObject *code = (AVMetadataMachineReadableCodeObject*)[_previewLayer transformedMetadataObjectForMetadataObject:obj];
        Barcode *barcode = [self processMetadataObject:code];
        [foundBarcodes addObject:barcode];
    }
}

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection
{
    NSMutableSet *foundBarcodes = [[NSMutableSet alloc] init];
    
    [metadataObjects enumerateObjectsUsingBlock:^(AVMetadataObject *obj, NSUInteger idx, BOOL *stop)
     {
         [self findAndBuildBarcodes:foundBarcodes obj:obj];
         
         ...(略)    
             
        });
         
     }];
    
         ...(略) 
}

重構12:質疑 captureOutput:didOutputMetadataObjects 方法內,NSMutableSet *foundBarcodes 的合理範圍。

原程式:

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection
{
    NSMutableSet *foundBarcodes = [[NSMutableSet alloc] init];
    
    [metadataObjects enumerateObjectsUsingBlock:^(AVMetadataObject *obj, NSUInteger idx, BOOL *stop)
     {
              ...(略) 
    }];
    
         ...(略) 
}

決定縮小其範圍,並以 findAndBuildBarcodes 取代 findAndBuildBarcodes:obj。至少,看起來更明白 foundBarcodes 這個 set 只與和它真正有關係的程式碼在一起。(和「…略2」所忽略的程式碼無關)

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection
{
    
    [metadataObjects enumerateObjectsUsingBlock:^(AVMetadataObject *obj, NSUInteger idx, BOOL *stop)
     {
         NSMutableSet *foundBarcodes = [self findAndBuildBarcodes:obj];
         
         dispatch_sync(dispatch_get_main_queue(), ^{
                 ...(略)
        });
         
     }];
    
    ...(略2)
}


重構13:抽出 removeAllPreviewLayers 及 drawNewPreviewLayers

原程式:

dispatch_sync(dispatch_get_main_queue(), ^{
             // Remove all old layers
             NSArray *allSublayers = [_previewView.layer.sublayers copy];
             [allSublayers enumerateObjectsUsingBlock:^(CALayer *layer, NSUInteger idx, BOOL *stop) {
                 if (layer != _previewLayer) {
                     [layer removeFromSuperlayer];
                 }
             }];
             
             // Add new layers
             [foundBarcodes enumerateObjectsUsingBlock:^(Barcode *barcode, BOOL *stop) {
                 CAShapeLayer *boundingBoxLayer = [CAShapeLayer new];
                 boundingBoxLayer.path = barcode.boundingBoxPath.CGPath;
                 boundingBoxLayer.lineWidth = 2.0f;
                 boundingBoxLayer.strokeColor = [UIColor greenColor].CGColor;
                 boundingBoxLayer.fillColor = [UIColor colorWithRed:0.0f green:1.0f blue:0.0f alpha:0.5f].CGColor;
                 [_previewView.layer addSublayer:boundingBoxLayer];
                 
                 CAShapeLayer *cornersPathLayer = [CAShapeLayer new];
                 cornersPathLayer.path = barcode.cornersPath.CGPath;
                 cornersPathLayer.lineWidth = 2.0f;
                 cornersPathLayer.strokeColor = [UIColor blueColor].CGColor;
                 cornersPathLayer.fillColor = [UIColor colorWithRed:0.0f green:0.0f blue:1.0f alpha:0.5f].CGColor;
                 [_previewView.layer addSublayer:cornersPathLayer];

             }];

重構後:

現在可以一眼就看出:在 dispatch_sync 中,主要是在 main queue 中完成兩件工作。

dispatch_sync(dispatch_get_main_queue(), ^{
             [self removeAllPreviewLayers];
             [self drawNewPreviewLayers:foundBarcodes];

});

- (void)removeAllPreviewLayers
{
    NSArray *allSublayers = [_previewView.layer.sublayers copy];
    [allSublayers enumerateObjectsUsingBlock:^(CALayer *layer, NSUInteger idx, BOOL *stop) {
        if (layer != _previewLayer) {
            [layer removeFromSuperlayer];
        }
    }];
}

- (void)drawNewPreviewLayers:(NSMutableSet *)foundBarcodes
{
    [foundBarcodes enumerateObjectsUsingBlock:^(Barcode *barcode, BOOL *stop) {
        CAShapeLayer *boundingBoxLayer = [CAShapeLayer new];
        boundingBoxLayer.path = barcode.boundingBoxPath.CGPath;
        boundingBoxLayer.lineWidth = 2.0f;
        boundingBoxLayer.strokeColor = [UIColor greenColor].CGColor;
        boundingBoxLayer.fillColor = [UIColor colorWithRed:0.0f green:1.0f blue:0.0f alpha:0.5f].CGColor;
        [_previewView.layer addSublayer:boundingBoxLayer];
        
        CAShapeLayer *cornersPathLayer = [CAShapeLayer new];
        cornersPathLayer.path = barcode.cornersPath.CGPath;
        cornersPathLayer.lineWidth = 2.0f;
        cornersPathLayer.strokeColor = [UIColor blueColor].CGColor;
        cornersPathLayer.fillColor = [UIColor colorWithRed:0.0f green:0.0f blue:1.0f alpha:0.5f].CGColor;
        [_previewView.layer addSublayer:cornersPathLayer];
    }];
}

其餘重構:大抵上依循前面步驟,對 creatPathToFirstCorner:code: 方法進行重構。

原程式:

- (Barcode *)processMetadataObject:(AVMetadataMachineReadableCodeObject*)code {
    
    // 1  Query the dictionary of Barcode objects to see if a Barcode with the same contents is already cached.
    Barcode *barcode = [self findOrCreateNewBarcode:code];
    
    // Create the path joining code's corners
    
    // 4 Instantiate cornersPath to store the path joining the four corners of the code.
    CGMutablePathRef cornersPath = CGPathCreateMutable();
    
    // 5 Convert the first corner coordinate to CGPoint instances using some CoreGraphics calls.
    CGPoint point;
    CGPointMakeWithDictionaryRepresentation((CFDictionaryRef)code.corners[0], &point);
    
    // 6 Begin the path at the corner defined in Step 5.
    CGPathMoveToPoint(cornersPath, nil, point.x, point.y);
    
    // 7 Loop through the other three corners, creating the path as you go.
    for (int i = 1; i < code.corners.count; i++) {
        CGPointMakeWithDictionaryRepresentation((CFDictionaryRef)code.corners[i], &point);
        CGPathAddLineToPoint(cornersPath, nil, point.x, point.y);
    }
    
    // 8 Close the path by joining the fourth point to the first point.
    CGPathCloseSubpath(cornersPath);
    
    // 9  Create a UIBezierPath object from cornersPath and store it in the Barcode object
    
    barcode.cornersPath = [UIBezierPath bezierPathWithCGPath:cornersPath];
    CGPathRelease(cornersPath);
    
    // Create the path for the code's bounding box
    
    // 10 Create the bounding box path using bezierPathWithRect:.
    barcode.boundingBoxPath = [UIBezierPath bezierPathWithRect:code.bounds];
    
    // 11  Finally, return the Barcode object.
    return barcode;

}

重構後:

- (Barcode *)processMetadataObject:(AVMetadataMachineReadableCodeObject*)code {
    
    Barcode *barcode = [self findOrCreateNewBarcode:code];
    
    CGMutablePathRef cornersPath = [self creatBarcodeCornerPathes:code];
    
    [self setupBarcodePreviewPath:cornersPath barcode:barcode code:code];
    
    return barcode;

}

- (Barcode *)findOrCreateNewBarcode:(AVMetadataMachineReadableCodeObject *)code {
    
    Barcode *barcode = _barcodes[code.stringValue];
    
    if (barcode == nil) {
        barcode = [Barcode new];
        _barcodes[code.stringValue] = barcode;
    }
    
    barcode.metadataObject = code;
    
    return barcode;
}

- (CGMutablePathRef)creatBarcodeCornerPathes:(AVMetadataMachineReadableCodeObject *)code {
    
    CGPoint point;
    CGMutablePathRef cornersPath = [self creatPathToFirstCorner:&point code:code];
    
    [self buildPathesWithAllCorners:point cornersPath:cornersPath code:code];
    
    CGPathCloseSubpath(cornersPath);
    
    return cornersPath;
}

- (CGMutablePathRef)creatPathToFirstCorner:(CGPoint *)point_p code:(AVMetadataMachineReadableCodeObject *)code {
    
    CGMutablePathRef cornersPath = CGPathCreateMutable();
    CGPointMakeWithDictionaryRepresentation((CFDictionaryRef)code.corners[0], &(*point_p));
    CGPathMoveToPoint(cornersPath, nil, point_p->x, point_p->y);
    
    return cornersPath;
}

- (void)buildPathesWithAllCorners:(CGPoint)point cornersPath:(CGMutablePathRef)cornersPath code:(AVMetadataMachineReadableCodeObject *)code {
    for (int i = 1; i < code.corners.count; i++) {
        CGPointMakeWithDictionaryRepresentation((CFDictionaryRef)code.corners[i], &point);
        CGPathAddLineToPoint(cornersPath, nil, point.x, point.y);
    }
}

- (void)setupBarcodePreviewPath:(CGMutablePathRef)cornersPath
                        barcode:(Barcode *)barcode
                           code:(AVMetadataMachineReadableCodeObject *)code {
    
    barcode.cornersPath = [UIBezierPath bezierPathWithCGPath:cornersPath];
    CGPathRelease(cornersPath);
    
    barcode.boundingBoxPath = [UIBezierPath bezierPathWithRect:code.bounds];
}


結語:

本範例主要在表達一些重構的觀念及技巧,其實在一個步驟內就還包含了數個小步驟,並不像本文中講的那麼簡潔。假若讀者有一起跟著操作,一定知道我在說什麼。

所有的程式碼均集中在 ViewController.m 中,所以這樣的重構結果,算是很基礎的。不過,這卻是重要的基礎。假設,未來有任何功能性需求的擴充,或是希望將這份程式碼延伸成樣版、模組,或是做為其他應用的基礎,相信妥善重構後的結果,一定能讓上述種種變更需求都顯得更優雅、更可行。至少,對於撰寫者而言,應該能保持較佳的工作心情及效率。

最後注意到的一點是:這個範例,正好是很不容易進行 TDD 的範例。至少目前我還沒找到可以把 UIImage 以 AVMetadataMachineReableCodeObject 進行解析的方式。不過儘管如此,在每一個步驟之間,重構者都必須得要不斷進行測試,並確定功能無損後才進行 commit,這個紀律是非常重要的。

2014年3月28日 星期五

Tweaks Framework

Tweaks::Facebook

Tweaks 是由 Facebook 三天前(2014.3.25)所發佈的一套用於協助 prototyping 的框架。

在進行 App 的視覺設計時,最準確的方式就是:將 App 佈署到行動裝置上,並且實際在各種場合下進行各種操作。

什麼叫「各種場合」?

例如:
一套支援計步健身的 App,其色彩設計上的規劃必須考慮使用者在大太陽之下能不能看得清楚?
以銀髮族為角色的 App,其字體大小的最小尺寸、以及不會破壞排版的最大尺寸為多少?
App 內的動畫播放速度要多快不會太擔誤使用者的時間?但是又能看得清楚、得到預想的效果?

什麼叫「各種操作」?

例如:
對於單手持握裝置的使用者而言,App 內提供的按鈕位置是否容易觸發?是否操作旅行的路徑最短?

上述需求,可能會引起的因應動作是:開發者得不斷在程式碼/設定檔中調整各項參數,然後再一次進行至行動裝置的佈署及執行。顯然,有點花時間,對吧?而且,決定視覺設計的人,也可能不是程式開發者本身,這樣一來,要確定哪一個參數合適的過程中的溝通成本就不低。

Tweaks 可以讓上述情境 smooth 一些。

我從 FBTweakExample 中的程式碼來進行解釋。

一、FBTweakValue

_rootViewController.view.backgroundColor = [UIColor colorWithRed:0.9
                                                        green:0.9
                                                         blue:0.9
                                                        alpha:1.0];

上面的程式碼設定了 root 這個視圖控制器所管理的視圖的背景色,以RGB & Alpha 值進行設定。
改寫成下方 statement :

_rootViewController.view.backgroundColor

[UIColor colorWithRed:FBTweakValue(@"Window", @"Color", @"Red", 0.9, 0.0, 1.0)       
                green:FBTweakValue(@"Window", @"Color", @"Green", 0.9, 0.0, 1.0)
                 blue:FBTweakValue(@"Window", @"Color", @"Blue", 0.9, 0.0, 1.0alpha:1.0];

這裡使用了 FBTweakValue 。是什麼意思呢?我們直接來看輸出:



第一個畫面是由 Tweaks 這個框架所提供的 (FBTweakViewController)。請注意看到第一個畫面中的「Window」row 和第二畫面中的「COLOR」session,請對應於上面程式碼中 FBTweakValue 的第一、第二個參數。而第二畫面中的 Red 就是對應第三個參數。我想大家也能猜出:第四個參數就是「預設值 0.9」。第五、第六個參數是最小值和最大值。

也就是說:只要我們將程式裡的某個值 (value) 代換成 FBTweakValue 這個 macro 的話,在 FBTweakViewController 啟動後就會把前三項參數產生為第一畫面列表中的一個列 (UITableViewCell)、第二畫面中的分段(Section) 及 分段 中的列。

以此例來說,提供了浮點數型別的三個數字作為第4~6個參數,FBTweakViewController 會自動生成 step 元件(兩個按鈕,一個減一個加)。

我們可以透過點按 step 元件來改變顏色的RGB值。

要注意的一點是:設定完之後,要把 App 先從背景移除後再開啟,該值才會生效。什麼?不能直接生效嗎?可以,不過得換個 macro 來用:FBTweakBind。


二、FBTweakBind

_label.text = @“Tweaks”;

上方的程式碼中,把標籤 _label 的文字設定為 Content。我們以下方的程式進行改寫:

  FBTweakBind(_label, text, @"Content", @"Text", @"String", @"Tweaks");

第一個參數是 UI 元件,第二個參數則是該元件的屬性第三 ~ 五個參數…直接看圖吧…,第六個參數則為預設值。


可以看出來,第三~五個參數是怎麼被產生出設定用的 UI 元件的。
這裡因為用的是 FBTweakBind 這個 macro ,所以值被變更的同時,回到 App 去看的話,就能馬上看到效果的變化。


三、FBTweakInline

上面提及的兩個 macro 有個共同之處:都是和某個 UI 元件的特定屬性相關。透過 FBTweakInline 可以用觀察者模式對某個設定項的值進行偵測,並於該值改變時調整 App 內任何的設定參數(不綁特定 UI 元件)。直接看實例吧:

_flipTweak = FBTweakInline(@"Window", @"Effects", @"Upside Down", NO);
  [_flipTweak addObserver:self];

- (void)tweakDidChange:(FBTweak *)tweak
{
  if (tweak == _flipTweak) {
    _window.layer.sublayerTransform = CATransform3DMakeScale(1.0, [_flipTweak.currentValue boolValue] ? -1.0 : 1.0, 1.0);
  }
}

  

畫面上的  EFFECTS -> Upside Down 預設值為 NO,並由程式碼的 host class 作為觀察者。只要 Upside Down 的值發者變化,則 call-back method : tweakDidChange 會被呼叫,然後就可以在該 method 內指定 App 要因應做何改變了(此範例是把螢幕轉180度,變成上下顛倒)。


以上就是 FBTweakExample 中和 FBTweaks 框架有關的示範了。


結語:

1. 可以用於 App 的設計階段。假設 coder 運用地夠純熟,大概不會太影響開發速度;UI designer 可以自行透過介面做各項參數的調整而不需要一再和 coder 反複針對單一參數進行溝通。
2. FBTweakExample 的寫法是為了容易說明,不過在程式碼中嵌入 FBTweak macro 時也會對程式碼 intention 的表達有影響:閱讀流暢度會受影響。要有更好的封裝:同時也得防止封裝影響偵錯。
3. 用於 Production … 嗯…持保留態度。
4. 可以看出這套框架應該能減少 UI design coder 之間的溝通成本,這個應該很合適以不斷修正體驗設計的開發團隊。對於 UI design program develop 流程分開且 one-step 執行 (non-iteration) 的團隊則不適用。