序
地鐵通是一款覆蓋了國(guó)內(nèi)全部和國(guó)外部分已開通地鐵城市的導(dǎo)航類應(yīng)用憔鬼,入選了蘋果的AppStore精華圾另,至今仍在地鐵關(guān)鍵字搜索排名第一。迄今為止(注:2016年2月)iOS版的總下載數(shù)為237萬(wàn)术辐,蘋果后臺(tái)顯示的日活躍用戶約為5萬(wàn)。
我是從2013年起接手的這個(gè)應(yīng)用渡处,在那之前是一個(gè)在中國(guó)定居的法國(guó)同事Kevin搭起的總體架構(gòu),在SVN顯示的第一次提交是2012年6月14日祟辟。在這漫長(zhǎng)的時(shí)間里医瘫,由于可以想象的項(xiàng)目進(jìn)度和迭代壓力,重寫架構(gòu)這種費(fèi)力不討好的事兒自然一直沒(méi)法安排旧困。
事實(shí)上醇份,小規(guī)模的重構(gòu)(即,單個(gè)頁(yè)面規(guī)模)一直在進(jìn)行吼具,在2014年8月的一個(gè)版本我由于花了很大力氣重構(gòu)得比較狠僚纷,還惡搞蘋果的Jony Ive,用他的口吻做了幾頁(yè)宣傳PPT:
(此處有掌聲)
盡管如此拗盒,更大規(guī)模怖竭,或者說(shuō),更底層的架構(gòu)級(jí)重構(gòu)還是一直沒(méi)時(shí)間進(jìn)行陡蝇。由于橫跨多個(gè)業(yè)務(wù)場(chǎng)景痊臭,這種級(jí)別的重構(gòu)工作勢(shì)必占用幾周的完整時(shí)間,還要進(jìn)行徹底的測(cè)試登夫。作為一家創(chuàng)業(yè)公司是承受不起的广匙,只能想想。
正如Pinterest在介紹他們重寫程序架構(gòu)的blog(《Re-architecting Pinterest's iOS app》)里面寫的:
A small team of Pinterest iOS engineers was recently given the opportunity every engineer dreams of - completely rethinking and rebuilding our app.
直到我決定要離職悼嫉,出于一種代碼潔癖和少許的社會(huì)責(zé)任感(我覺得在我之后怕是沒(méi)人能做得起這種量級(jí)的代碼調(diào)整了艇潭,難道就把這一坨四年歷史的充滿補(bǔ)丁的垃圾留給200多萬(wàn)用戶嗎?)戏蔑,外加一股想要自我救贖的沖動(dòng)(我認(rèn)為蹋凝,能自己把自己的爛代碼改掉是一種自我救贖),我向Boss提出了重構(gòu)申請(qǐng)总棵,作為離職前的最后一個(gè)項(xiàng)目鳍寂。
地鐵通4.0項(xiàng)目就這么立項(xiàng)了。
程序概貌
在深入講解之前情龄,我先整體介紹一下地鐵通這款app的大致組成迄汛。
如上面的主界面截圖,地鐵通的主要功能就是顯示地鐵圖和查詢兩個(gè)站點(diǎn)間的最佳換乘路線骤视,大概的模塊劃分如下:
現(xiàn)存問(wèn)題
接下來(lái)鞍爱,讓我們分析一下在3.x版本的地鐵通里面都存在哪些需要改進(jìn)的問(wèn)題。
ViewController職責(zé)過(guò)多
這可以說(shuō)是任何一個(gè)未精心設(shè)計(jì)過(guò)架構(gòu)的iOS App必然會(huì)導(dǎo)致的結(jié)果专酗,在蘋果的MVC架構(gòu)中睹逃,ViewController這一級(jí)是最容易膨脹的,業(yè)界風(fēng)起云涌的諸如MVVM等架構(gòu)正是為了解決這種問(wèn)題。
具體到項(xiàng)目中沉填,比如在展示搜索結(jié)果的線路結(jié)果頁(yè)
中疗隶,當(dāng)線路數(shù)據(jù)傳到這個(gè)頁(yè)面時(shí),需要再次加工才能獲得該頁(yè)面所需要的信息(比如哪個(gè)站是換乘站翼闹、一共需要換乘幾次斑鼻、計(jì)算合理的末班車時(shí)間等),而這個(gè)工作全部放到了當(dāng)前ViewController中進(jìn)行猎荠。再加上其他一些處理邏輯坚弱,導(dǎo)致這個(gè)ViewController變得極端臃腫,達(dá)到了令人發(fā)指的3000多行关摇。
再比如切換不同城市地鐵的城市列表頁(yè)
中史汗,由于沒(méi)有做好適當(dāng)?shù)姆謱樱瑢?dǎo)致這個(gè)ViewController除了顯示城市列表的任務(wù)之外拒垃,還承擔(dān)了:
- 點(diǎn)選切換城市之后的數(shù)據(jù)初始化工作;
- 點(diǎn)選開始下載后的進(jìn)度更新邏輯瓷蛙,甚至下載完成后的解壓邏輯悼瓮;
- 點(diǎn)選刪除城市之后的數(shù)據(jù)清理工作;
- ...
耦合過(guò)緊
從上面的結(jié)構(gòu)模塊示意圖中可以看出艰猬,在不同模塊之間存在著錯(cuò)綜復(fù)雜的強(qiáng)依賴横堡,其中尤以DataSource
最為嚴(yán)重,因?yàn)樵谡麄€(gè)App運(yùn)行期間都要保持不同界面的某種數(shù)據(jù)一致性冠桃。雖然在之前的架構(gòu)中特地抽象出了DataSource這個(gè)單例命贴,但各個(gè)模塊間都是直接對(duì)其進(jìn)行操作,不僅繁瑣食听、容易出錯(cuò)胸蛛,從架構(gòu)的角度來(lái)看,某些底層的操作完全不應(yīng)該由最上層的ViewController來(lái)做樱报。
再舉個(gè)例子葬项,在3.x版本的地圖控件中(即上圖的TileView
),存在著大量處理和用戶交互的代碼邏輯迹蛤,甚至還有用戶點(diǎn)選起點(diǎn)和終點(diǎn)后直接對(duì)DataSource
進(jìn)行更新的邏輯民珍。過(guò)緊的耦合導(dǎo)致了不必要的判斷,到現(xiàn)在我都無(wú)法完全搞清之前同事寫的下面這幾行代碼中盗飒,那兩個(gè)神奇的數(shù)字到底是干嘛的:
NSInteger tag = -1;
if ([sender isKindOfClass:[UIButton class]]) {
UIButton *tmpBtn = (UIButton *)sender;
tag = tmpBtn.tag;
}
if (sender == bubbleView.startBtn || tag == 888 || tag == 887) { // WTF??
...
}
產(chǎn)品邏輯混亂
這條看起來(lái)可能有點(diǎn)甩鍋的意思嚷量,但實(shí)際情況確實(shí)如此:如果有哪條邏輯寫起來(lái)非常擰巴,或者連描述起來(lái)都擰巴的時(shí)候逆趣,你可以去認(rèn)真思考一下蝶溶,是不是從產(chǎn)品設(shè)計(jì)上就出了問(wèn)題。
雖然從流程上講汗贫,這種上溯應(yīng)該扼殺在需求評(píng)審之類的時(shí)機(jī)身坐,但從實(shí)際執(zhí)行的角度秸脱,已經(jīng)完成的功能并不代表就是不可更改的。尤其在發(fā)現(xiàn)那部分代碼已經(jīng)成為維護(hù)地獄的時(shí)候部蛇,應(yīng)該當(dāng)機(jī)立斷進(jìn)行修正摊唇。
比如在書簽/歷史線路頁(yè)面,以前產(chǎn)品的需求是查詢過(guò)的站點(diǎn)和線路按時(shí)間逆序混雜在一起顯示涯鲁,這樣導(dǎo)致的結(jié)果就是巷查,在構(gòu)造這個(gè)頁(yè)面時(shí)需要大量的動(dòng)態(tài)判斷邏輯來(lái)區(qū)分每個(gè)cell到底是什么東西:
NSObject *obj = [historyMArray objectAtIndex:row];
if ([obj isKindOfClass:[NSDictionary class]]) {
...
}else if ([obj isKindOfClass:[Route class]]){
...
}else if ([obj isKindOfClass:[NSArray class]]){
...
}
return cell;
而如果把頁(yè)面分為若干個(gè)section顯示,不僅從根源上杜絕了這種混亂的邏輯抹腿,從產(chǎn)品的角度也使用戶一眼看上去更加清晰岛请。
頁(yè)面布局方式過(guò)時(shí)
可能對(duì)于大部分現(xiàn)有的App來(lái)說(shuō),這已經(jīng)不算是個(gè)問(wèn)題警绩,但對(duì)于一個(gè)有著幾年歷史的App崇败,基本上最早的那些界面依然是用絕對(duì)坐標(biāo)進(jìn)行定位的;加載一個(gè)動(dòng)態(tài)生成的UI時(shí)肩祥,也是一點(diǎn)點(diǎn)計(jì)算后再addSubview
上去后室,或者修改某個(gè)控件的frame.size.height
來(lái)更新其高度。這種做法不僅不優(yōu)雅混狠,更重要的是不直觀岸霹。一切不直觀的代碼都會(huì)導(dǎo)致以下幾點(diǎn)問(wèn)題:
- 開發(fā)時(shí)難以調(diào)試;
- 出現(xiàn)bug時(shí)難以維護(hù)将饺;
- 拿到新需求時(shí)難以修改贡避。
所謂的KISS原則(Keep It Simple & Stupid),指的就是這樣的場(chǎng)景予弧。
知道了現(xiàn)有的問(wèn)題刮吧,下面要做的就是一些合理的規(guī)劃了。
架構(gòu)設(shè)計(jì)
有了上面所述的教訓(xùn)掖蛤,在設(shè)計(jì)新版架構(gòu)的時(shí)候皇筛,從最開始就確立了幾條原則:
- 盡量打散耦合,只在有必要的情況下采用緊耦合坠七;
- 切記合理封裝水醋,每個(gè)模塊嚴(yán)格只負(fù)責(zé)自己的事務(wù);
- 爭(zhēng)取保持簡(jiǎn)潔彪置,遇到復(fù)雜的實(shí)現(xiàn)換用簡(jiǎn)單的設(shè)計(jì)拄踪。
下面是我當(dāng)時(shí)在日記本上畫的下載模塊的層次結(jié)構(gòu)示意圖:
每一層之間僅保留最基本的數(shù)據(jù)請(qǐng)求和數(shù)據(jù)反饋。
新的結(jié)構(gòu)圖大概是這個(gè)樣子:
最明顯的區(qū)別就是把模塊之間的緊耦合變成了虛線顯示的Notification拳魁,并且去掉了所有跨級(jí)調(diào)用惶桐。
準(zhǔn)備工作
在開始項(xiàng)目之前,一個(gè)要做的選擇是,要用原有的Objective-C還是新生的Swift姚糊?
最終我選擇了Swift贿衍。理由是:
- Swift經(jīng)過(guò)幾年的發(fā)展,已經(jīng)趨于穩(wěn)定救恨;
- 此次重寫的目標(biāo)就是要面向未來(lái)贸辈,而Swift就是未來(lái);
- 借機(jī)學(xué)習(xí)新的技術(shù)肠槽。
然后擎淤,我用"Swift best practices"作為關(guān)鍵詞搜索進(jìn)行了一番調(diào)研,盡量從最開始就站在巨人的肩膀上秸仙,往正確的方向走嘴拢。
比如這篇:https://github.com/futurice/ios-good-practices
一些在項(xiàng)目中采納的點(diǎn):
使用struct
來(lái)定義常量
在Objective-C時(shí)代經(jīng)常用#define
來(lái)定義常量,現(xiàn)在可以使用struct
來(lái)定義寂纪。比如:
swift struct CMConfig { static let AppName: String = "xxx" static let AppStoreID : UInt = 8888 static let WeiboKey: String = "8888" static let WeiboAppSecret: String = "8888" static let AppStoreURL: NSURL = NSURL(string: "itms-apps://itunes.apple.com/cn/app/bei-jing-de-tie-touchchina/id530096786")! ... }
這樣可以最大程度地利用Swift類型安全的語(yǔ)言特性席吴,使你在編譯期就可以檢查出一些低級(jí)的失誤。
使用Asset Catalogs
來(lái)圖像資源文件
之前的圖像資源管理非忱痰埃混亂抢腐,不僅所有文件混雜在目錄中,而且每個(gè)資源對(duì)應(yīng)著兩到三個(gè)不同分辨率的@2x, @3x傳統(tǒng)位圖襟交。
采取Asset Catalog進(jìn)行統(tǒng)一管理后,不僅工程干凈清爽了許多伤靠,還可以用一張矢量圖來(lái)讓XCode自動(dòng)為你生成不同分辨率的資源捣域。記得我剛才說(shuō)的“面向未來(lái)”嗎?xD
(沒(méi)錯(cuò)宴合,矢量圖是我自己拿Adobe Illustrator重繪的焕梅。)
設(shè)定合理的Logging策略
之前的項(xiàng)目中一直采用NSLog
,這樣的弊端是在正式上線的App中很難把所有NSLog都屏蔽掉卦洽,而這些輸出的記錄會(huì)影響程序效能贞言,還可能泄露隱私。
如果在一開始就設(shè)定合理的Logging分級(jí)機(jī)制阀蒂,這樣在打發(fā)布包的時(shí)候只需把Looging Level調(diào)高(甚至可以在切換scheme的時(shí)候自動(dòng)進(jìn)行調(diào)整)该窗,就能杜絕上述問(wèn)題。
使用預(yù)處理標(biāo)志
直接看圖:
使用Preprocessor Symbol的好處是什么呢蚤霞?還以剛才的常量定義為例:
struct CMConfig {
...
#if Debug
static let BaseURL : NSURL = NSURL(string: "https://test.itouchchina.com")!
static let CMBPushMode : BPushMode = BPushMode.Development
static let CMLogLevel : SLogLevel = SLogLevel.Verbose // 百度推送模式
static let CMDebugStatus : Bool = true
static let CMHeaderClass: String = "test"
#else
static let BaseURL : NSURL = NSURL(string: "https://production.itouchchina.com")!
static let CMBPushMode : BPushMode = BPushMode.Production
static let CMLogLevel : SLogLevel = SLogLevel.Error
static let CMDebugStatus : Bool = false
static let CMHeaderClass: String = "production"
#endif
}
如此便可以一勞永逸地解決各種常量在開發(fā)版和正式版中的切換問(wèn)題酗失,你從此可以忘掉它,在切換scheme的時(shí)候一切都會(huì)自動(dòng)調(diào)整昧绣,免去了可能的錯(cuò)誤规肴。
如果你有更多的scheme,希望更細(xì)粒度地進(jìn)行控制,可以使用Swift Flag:
在此不再贅述拖刃。
使用CocoaPods進(jìn)行第三方庫(kù)管理
這點(diǎn)應(yīng)該大家都熟悉删壮,誰(shuí)用誰(shuí)知道。
詳細(xì)分析
下面選幾個(gè)有代表性的頁(yè)面兑牡,分別說(shuō)明一下改進(jìn)的細(xì)節(jié)央碟。
城市列表頁(yè)
問(wèn)題
如前所述,UI層代碼发绢、交互層代碼硬耍、響應(yīng)層代碼和數(shù)據(jù)處理以及初始化代碼沒(méi)有合理分離。
-
城市的下載狀態(tài)沒(méi)有一個(gè)統(tǒng)一的位置持有边酒。
設(shè)計(jì)者的本意是通過(guò)進(jìn)入該頁(yè)面時(shí)生成的一個(gè)包含所有City實(shí)例的NSArray
管理经柴,可一旦開始下載,就要涉及DownloadManager
模塊里面的下載狀態(tài)和當(dāng)前頁(yè)面數(shù)組中的狀態(tài)之間的同步等一系列問(wèn)題墩朦。
同時(shí)坯认,我們會(huì)發(fā)現(xiàn)每次在設(shè)置城市的下載狀態(tài)時(shí)都會(huì)進(jìn)行很多判斷,這些判斷還有不少是重復(fù)的氓涣。更嚴(yán)重的問(wèn)題是牛哺,一個(gè)后續(xù)的錯(cuò)誤狀態(tài)也許會(huì)覆蓋掉之前設(shè)置過(guò)的正確狀態(tài)(因?yàn)樵诰植繘](méi)有足夠的上下文信息對(duì)狀態(tài)進(jìn)行充分的判斷)。舉個(gè)例子:
if (cell.state == CGListTableCellStateEdit) { NSString * versionFile = getCGDocumentPath([NSString stringWithFormat: @"guides/guide%d/current.version", [guide.metroAppId intValue]]); NSString * version = [NSString stringWithContentsOfFile: versionFile encoding: NSUTF8StringEncoding error: nil]; NSFileManager * fm = [NSFileManager defaultManager]; if ([fm fileExistsAtPath:versionFile]) { if ((version != nil) && ([guide.guideupdate compare: version] != NSOrderedSame)) cell.guide.cellState = CGListTableCellStateUpdate; else cell.guide.cellState = CGListTableCellStateDownloaded; }else{ cell.guide.cellState = CGListTableCellStateDownloaded; } [self reloadTableView]; }
這只是一個(gè)簡(jiǎn)單的當(dāng)用戶點(diǎn)擊一個(gè)編輯狀態(tài)的城市時(shí)應(yīng)該采取的動(dòng)作劳吠,但此處的實(shí)現(xiàn)居然恐怖到動(dòng)用了一個(gè)
NSFileManager
引润!而這一切僅僅是為了判斷這個(gè)城市應(yīng)該回復(fù)到已下載狀態(tài)還是有更新狀態(tài)。類似的代碼還可以見到好幾處(在該頁(yè)搜索
NSFileManager
痒玩,有多達(dá)19個(gè)結(jié)果4靖健)。 -
最后一個(gè)問(wèn)題也是上面提到的蠢古,搭建UI采用絕對(duì)坐標(biāo)奴曙。
甚至有這樣惡心的代碼:- (void)formatUI{ CGSize size = [cityNameLabel.text sizeWithFont:cityNameLabel.font]; CGRect rect; rect = cityNameLabel.frame; rect.size.width = size.width; cityNameLabel.frame = rect; if (size.width > 74) { // Why 74? CGRect rect; rect = sizeLabel.frame; rect.origin.x = 80 + size.width - 74; // ??? sizeLabel.frame = rect; ... } }
#### 改進(jìn)
##### 下載管理
在地鐵通4.0中,我把所有對(duì)下載狀態(tài)的管理一概收到了下載模塊`CMDataAPI`中草讶,現(xiàn)在獲取下載狀態(tài)只要一行:
```swift
var currentMode: CMCityListCellMode = CMDataAPI.sharedInstance.getModeForCity(city)
在getModeForCity(_city: CMCity)
的實(shí)現(xiàn)中洽糟,只需遍歷下載和解壓兩個(gè)隊(duì)列,如果有必要對(duì)結(jié)果再進(jìn)行時(shí)間戳的比對(duì)即可堕战。(順便坤溃,我把下載的時(shí)間戳遷移到了NSUserDefaults
里,避免了文件操作嘱丢,并在運(yùn)行時(shí)做了緩存浇雹。)
進(jìn)度更新
而下載進(jìn)度和解壓進(jìn)度的更新,也被封裝成了兩個(gè)方法屿讽,只暴露給調(diào)用頁(yè)面需要的參數(shù)(之前的版本中昭灵,解壓進(jìn)度甚至是無(wú)法查看的):
public func hookUpDownloadingProgress
(_progress: ((UInt, Int64, Int64) -> Void)?,
success: (CMCity -> Void)?,
failure: (CMCity -> Void)?,
forCity city: CMCity){
...
}
public func hookUpUnzippingProgess
(_progress: ((Int, Int) -> Void)?,
success: (CMCity -> Void)?,
forCity: city: CMCity){
...
}
與地圖頁(yè)的解耦合
在切換城市時(shí)吠裆,對(duì)地圖頁(yè)的直接調(diào)用改為發(fā)NSNotification
:
NSNotificationCenter.defaultCenter().postNotificationName(PropertyKeys.kCMCityChangeNotification, object: nil)
注意所有的廣播名稱常量也用前述的struct
進(jìn)行了統(tǒng)一的定義。
轉(zhuǎn)移數(shù)據(jù)操作
所有的數(shù)據(jù)初始化全部移到CMDataSource
中進(jìn)行:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
CMDataSource.sharedInstance.loadCity(city) // One line to rule them all!
dispatch_async(dispatch_get_main_queue(), {[unowned self] () -> Void in
SVProgressHUD.dismiss()
self.navigationController?.dismissViewControllerAnimated(true, completion: nil)
})
})
刪除城市數(shù)據(jù)亦然:
CMDataSource.sharedInstance.removeCity(city)
使用Storyboard/Xib
將UI搭建改為Storyboard/Xib這件事早有很多討論烂完,略去不表试疙。
成果
文件 | 地鐵通V3.x | 地鐵通V4.0 |
---|---|---|
CityListViewController | 1712行 | 280行 |
CityListCell | 與上面是同一個(gè)文件 | 123行 |
線路結(jié)果頁(yè)
問(wèn)題
-
如前所述,對(duì)于線路數(shù)據(jù)需要再進(jìn)行加工才能使用抠蚣。
如圖祝旷,光是確定各種情況下的末班車時(shí)間就有下面一大堆方法:
在
tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
里就更加慘不忍睹。 -
這個(gè)頁(yè)面在搭建UI上的問(wèn)題更加嚴(yán)重嘶窄。
由于每次生成的路線有無(wú)數(shù)種可能的組合怀跛,比如該城市的數(shù)據(jù)庫(kù)中有沒(méi)有出租車信息、起點(diǎn)終點(diǎn)是地鐵站還是用戶輸入的地點(diǎn)柄冲、進(jìn)行百度/Google搜索時(shí)有搜索中和搜索完成/失敗三種狀態(tài)……導(dǎo)致整個(gè)UI是在不停變化的吻谋,而未采用AutoLayout中約束條件的弊端在這種情況下體現(xiàn)得淋漓盡致。惡心代碼欣賞:
if (taxiFee.floatValue < 0.01) { [headerView removeTaxi]; } if(discountRate > 0){ [headerView setDiscountRateUI]; CGRect headerFrame = headerView.frame; headerFrame.size.height += CM_ROUTE_SUM_MARGIN; headerView.frame = headerFrame; }
曾經(jīng)設(shè)計(jì)師在挑UI問(wèn)題的時(shí)候提出某處的空白留的不夠现横,當(dāng)時(shí)的我盤算了下在這種紛雜狀況下想要精確控制空白高度的工作量……決定把這個(gè)bug放到最后再改漓拾,后來(lái)其他需求一來(lái)就不了了之了……
直接操作現(xiàn)象。
在數(shù)據(jù)變動(dòng)要更新UI的時(shí)候戒祠,經(jīng)常出現(xiàn)直接去更新相關(guān)View控件的代碼骇两。初看起來(lái)這沒(méi)什么問(wèn)題,但還是剛才說(shuō)的問(wèn)題姜盈,當(dāng)這涉及到控件大小的改動(dòng)時(shí)低千,就非常令人頭疼了。
改進(jìn)
引入新數(shù)據(jù)類型
引入新的Route
和RouteNode
對(duì)象馏颂,將所有與表現(xiàn)層無(wú)關(guān)的數(shù)據(jù)構(gòu)建代碼一律移入這里示血。
順便锐朴,還為今后可能的變動(dòng)做了擴(kuò)展:
convenience init(start:CMPOI, end:CMPOI, algorithm: CMRouteAlgorithm, type: CMRouteType)
在初始化對(duì)象的時(shí)候靈活傳入所需的算法呵晚,可以實(shí)現(xiàn)靈活變換不同的算法計(jì)算路線吧凉。之前出現(xiàn)過(guò)針對(duì)海外城市采取不同的路線計(jì)算方式,當(dāng)時(shí)的實(shí)現(xiàn)弄得非常惡心近上,這樣就優(yōu)雅很多。
再順便拂铡,這是策略模式
——在面試中經(jīng)常被問(wèn)到“你在項(xiàng)目中用了什么設(shè)計(jì)模式”這種惡心的問(wèn)題壹无,特地查了一下這種抽象叫什么。
現(xiàn)在感帅,即使是返回cell的回調(diào)方法里也清晰多了:
public func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let node = routeArray[indexPath.row]
if let type = node.type, let lineId = node.lineId{
switch type{
case .TimeAdjusting:
var cell: CMRouteTimeAdjustingCell? = tableView.dequeueReusableCellWithIdentifier(TimeAdjustingCellId) as? CMRouteTimeAdjustingCell
if cell == nil{
cell = CMRouteTimeAdjustingCell(style: UITableViewCellStyle.Default, reuseIdentifier: TimeAdjustingCellId)
}
... // Initialization code
return cell!
case .Normal:
var cell: CMRouteNormalStationCell? = tableView.dequeueReusableCellWithIdentifier(NormalStationCellId) as? CMRouteNormalStationCell
... // Same as above
return cell!
case .POI:
...
return cell!
case .Transfer, .Start, .Destination:
...
return cell!
}
return UITableViewCell()
}
使用約束條件建立UI
現(xiàn)在斗锭,整個(gè)頁(yè)面,包括cell失球,都是用Constraints建立起來(lái)的(使用了一個(gè)第三方庫(kù)叫SnapKit)岖是。
而且帮毁,cell里面的地鐵線路,是用CoreGraphic
自己畫的豺撑。
甚至烈疚,線路上的文字是黑色還是白色,都是通過(guò)算法自動(dòng)生成的(參考了這里的討論)聪轿。
這樣的靈活度基本上算是達(dá)標(biāo)了:)
PS. 在完成整個(gè)項(xiàng)目之后爷肝,我試著把新版app在iPad上跑了一下,驚訝地發(fā)現(xiàn)陆错,在我沒(méi)有改動(dòng)任何地方的情況下灯抛,居然所有頁(yè)面的UI都顯示正常!這在引入約束條件生成UI之前簡(jiǎn)直是不可想象的音瓷。
只更新數(shù)據(jù)源对嚼,不操作控件
事實(shí)上,這就是最基本的MVC分層在實(shí)際中的應(yīng)用——當(dāng)數(shù)據(jù)變化時(shí)外莲,產(chǎn)生變化的組件只負(fù)責(zé)更新DataModel
猪半,然后簡(jiǎn)單地調(diào)用tableView.reloadData()
搞定。剩下的問(wèn)題都由View
層自行負(fù)責(zé)偷线。
后來(lái)在瀏覽博客時(shí)發(fā)現(xiàn)有關(guān)這樣的改動(dòng)磨确,Eden有一篇《iOS APP 架構(gòu)漫談》差不多是同樣的場(chǎng)景,在那篇文章中解釋得非常詳盡声邦,尤其是:
我們可以借鑒很多”內(nèi)存管理中的規(guī)則”乏奥,比如
誰(shuí)創(chuàng)建,誰(shuí)銷毀
亥曹。同樣邓了,在我們的information flow中,我們希望誰(shuí)創(chuàng)建Cache媳瞪,誰(shuí)更新Cache變化
這一句骗炉,道破本質(zhì)。有興趣的可去觀摩蛇受。
成果
文件 | 地鐵通3.x | 地鐵通4.0 |
---|---|---|
RouteViewController | 3055行 | 1158行 |
RouteViewCell | 158+67+171+89+220+249+245 =1199行<1> |
400行 |
Route<2> | 1045行 | N/A |
DataModel<3> | N/A | 629行 |
DijkstraAlgorithm<4> | N/A | 320行 |
注:
- 3.x版本中分為NormalCell句葵、TransferCell、HeaderView兢仰、SummaryView等許多文件乍丈;
- 3.x版本中的Route文件其實(shí)是算法,生成一個(gè)不包含meta信息的
NSArray
把将; - 4.0版本中的DataModel定義了程序所需的所有底層對(duì)象轻专,不僅有Route,還有下載的城市以及車站等等察蹲;
- 這是計(jì)算圖上兩點(diǎn)間最短路徑的Dijkstra算法的實(shí)現(xiàn)请垛,從之前的Route文件中抽了出來(lái)催训。
書簽/歷史頁(yè)
這個(gè)頁(yè)面之前也提到過(guò),主要問(wèn)題是設(shè)計(jì)太混亂叼屠。
修改之后的邏輯相當(dāng)?shù)暮?jiǎn)潔瞳腌,想出錯(cuò)都難:
if indexPath.section == 0{//routes
let route = favRoutes[indexPath.row]
return getRouteCell(route as! NSDictionary)
}else{//stations
let station = favStations[indexPath.row]
return getStationCell(station)
}
成果是:
文件 | 地鐵通3.x | 地鐵通4.0 |
---|---|---|
BookmarkViewController | 784行 | 315行 |
BookmarkCell | 109+98+105=312行 | 90行 |
車站詳情頁(yè)
這個(gè)頁(yè)面的代碼量與之前基本保持一致,不同的是整體的UI搭建邏輯镜雨。
之前的搭法:從上到下依次初始化子控件并添加嫂侍,保持一個(gè)position
變量記錄當(dāng)前高度。
新版中的搭法:每個(gè)子控件負(fù)責(zé)處理自己的UI展現(xiàn)荚坞,子控件之間用約束條件加以綁定挑宠。
由于這個(gè)頁(yè)面的數(shù)據(jù)都是不確定的,因此只能用代碼添加數(shù)據(jù)元素間的約束條件颓影,這是與前面那些大體UI已定好只待往里填數(shù)據(jù)的頁(yè)面不同的地方各淀。
最終的畫風(fēng)大概是這樣的:
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(titleBackgroundView)
scrollView.addSubview(timetableView)
scrollView.addSubview(facilitiesView)
scrollView.addSubview(exitView)
self.view.addSubview(scrollView)
scrollView.snp_makeConstraints { (make) -> Void in
make.top.equalTo(self.view.snp_top)
make.width.equalTo(self.view.snp_width)
make.bottom.equalTo(floatingBGView.snp_top)
}
titleBackgroundView.snp_makeConstraints { (make) -> Void in
make.top.equalTo(scrollView.snp_top).priorityRequired()
make.width.equalTo(scrollView.snp_width).priorityRequired()
}
timetableView.snp_makeConstraints { (make) -> Void in
make.top.equalTo(titleBackgroundView.snp_bottom).offset(10.0)
make.width.equalTo(scrollView.snp_width)
make.centerX.equalTo(scrollView.snp_centerX)
}
facilitiesView.snp_makeConstraints { (make) -> Void in
make.top.equalTo(timetableView.snp_bottom).offset(20.0)
make.width.equalTo(scrollView.snp_width)
make.leading.equalTo(scrollView.snp_leading)
}
exitView.snp_makeConstraints { (make) -> Void in
make.top.equalTo(facilitiesView.snp_bottom).offset(20.0)
make.leading.equalTo(scrollView.snp_leading)
make.width.equalTo(scrollView.snp_width)
make.bottom.equalTo(scrollView.snp_bottom).offset(-Constants.StationDetailViewVerticalSpacing)
}
if let _station = station{
titleLabel.text = _station.stationName
iconsDisplayView.setImages(_station.getIcons())
timetableView.setup(_station.timeTables)
facilitiesView.setupUI(_station.facilities)
exitView.setupUI(_station.exits)
}
UI約束與數(shù)據(jù)初始化一氣呵成,而且清晰明了诡挂。(順便感謝Swift的鏈?zhǔn)秸Z(yǔ)法)
搜索頁(yè)
用到搜索頁(yè)的地方有兩處碎浇,一個(gè)是首頁(yè)點(diǎn)擊最上方進(jìn)入的頁(yè)面,一個(gè)是首頁(yè)點(diǎn)擊下方右邊按鈕進(jìn)入的頁(yè)面璃俗。兩者的不同是:前者有輸入起點(diǎn)和終點(diǎn)的工具欄奴璃,后者只是單純搜索定位車站。
問(wèn)題
之前的版本的兩個(gè)界面完全獨(dú)立城豁,等于許多邏輯重復(fù)了兩遍苟穆,違背了DRY(Don't Repeat Yourself)原則。
-
和線路詳情頁(yè)類似唱星,此處也缺少對(duì)數(shù)據(jù)合理的對(duì)象化封裝雳旅,導(dǎo)致在使用時(shí)需要大量的判斷邏輯。
比如间聊,會(huì)出現(xiàn)下面這樣的代碼片段:if ([workDict objectForKey:@"city"] != nil && [[workDict objectForKey:@"city"] isEqualToString:_cityName]) { _isShowingWork = YES; if ([workDict objectForKey:@"add"] == nil) { // Station ... } else { // POI [self updateHistoryItem:workDict]; } } else{ // Have not set ... }
這種直接對(duì)原始數(shù)據(jù)進(jìn)行操作的做法實(shí)在是既繁瑣又容易出錯(cuò)攒盈。
-
對(duì)子控件的直接更新,這點(diǎn)和前面的線路結(jié)果頁(yè)類似哎榴,但更加麻煩:由于當(dāng)前編輯狀態(tài)在隨時(shí)變化型豁,在決定更新控件時(shí)必須由零碎的變量來(lái)輔助判斷到底要對(duì)哪個(gè)控件進(jìn)行操作。
比如在百度定位API返回結(jié)果之后叹话,下面的這段響應(yīng)代碼:- (void)updatePOIFromLocation:(BMKPoiInfo *)info{ Station *station = [[CMDataSource getInstance] getNearestStation:info.pt.latitude andLo:info.pt.longitude]; if (isUpdatingStart) { ... // Do some work self.currentEditing = CMSearchEditingEnd; }else if (isUpdatingEnd){ ... // Doing work } if (isUpdatingStart) { isUpdatingStart = NO; _startIsShowingLocation = YES; } if (isUpdatingEnd) { isUpdatingEnd = NO; _endIsShowingLocation = YES; }
}
又要憑借狀態(tài)變量進(jìn)行判斷偷遗,又要記得時(shí)刻更新它們墩瞳,還得防止考慮不周更新錯(cuò)了狀態(tài)驼壶,簡(jiǎn)直是車禍現(xiàn)場(chǎng)。
#### 改進(jìn)
##### 合并兩個(gè)頁(yè)面
我直接把兩個(gè)頁(yè)面合并到了一起喉酌,由入口參數(shù)決定最終顯示哪個(gè)界面热凹。
但后來(lái)意識(shí)到泵喘,這并不是最好的解決方案。弊端是般妙,原來(lái)是毫無(wú)耦合造成重復(fù)纪铺,現(xiàn)在兩個(gè)頁(yè)面的耦合又太緊了,雖然在這版的需求中工作得很好碟渺,再有調(diào)整又會(huì)比較棘手鲜锚。更好的做法是使用`組合`,把公共的列表提取出來(lái)苫拍。
這一點(diǎn)在Casa的[《跳出面向?qū)ο笏枷?一) 繼承》](http://casatwy.com/tiao-chu-mian-xiang-dui-xiang-si-xiang-yi-ji-cheng.html)中解釋得非常好芜繁,我就不再贅述。
##### 新定義數(shù)據(jù)對(duì)象
在前面提到的`DataModel`中增加了`POI`對(duì)象绒极,包含`Station`作為它的一個(gè)property骏令。
這代表,現(xiàn)在整個(gè)界面中垄提,甚至整個(gè)app運(yùn)行期間榔袋,都只有一種數(shù)據(jù)對(duì)象——`CMPOI`,清爽铡俐,干凈凰兑,不緊繃。
##### 只更新數(shù)據(jù)源高蜂,不操作控件
基本同前聪黎,不贅述。
##### 成果
| 文件 | 地鐵通3.x | 地鐵通4.0|
|-----|:-------:|:------:|
|SearchStationViewController|2019行|469行|
|StationSearchView|821行|N/A|
### DataSource
#### 明確職責(zé)
這個(gè)組件作為整個(gè)app的數(shù)據(jù)中心备恤,在新版的開發(fā)中更加明確了它的職責(zé)稿饰,把原來(lái)分散在其他組件中的一些功能劃了過(guò)來(lái)。
比如露泊,之前城市列表讀取及解析功能是被劃到網(wǎng)絡(luò)請(qǐng)求組件中的喉镰,因?yàn)閺姆?wù)器拿過(guò)來(lái)的返回?cái)?shù)據(jù)可以就地解析;但從更高層的邏輯考慮這是不正確的惭笑,因?yàn)閷?duì)程序城市列表的需求是更直接的侣姆,網(wǎng)絡(luò)模塊只要做好本職的網(wǎng)絡(luò)交互就行了。
修改之后沉噩,網(wǎng)絡(luò)請(qǐng)求組件讀取成功城市列表后捺宗,并不直接返回,而是將列表保存到本地緩存起來(lái)川蒙,再發(fā)一個(gè)Notification通知到關(guān)心讀取成功這個(gè)事件的其他組件:
```swift
func updateCityList(){
... // Initialization code for paramaters
let param: [String : String] = ["param": JSONStringParam]
manager.POST(url, parameters: param, success: { (opreation, object) -> Void in
let data = object as! NSData
if CMDataSource.sharedInstance.loadXMLResponse(data) == true{
let documentPath = getDocumentPath("guides/guides.info")
data.writeToFile(documentPath, atomically: true)
NSNotificationCenter.defaultCenter().postNotificationName(PropertyKeys.kCMCityListUpdateSuccessNotification, object: nil)
}
}) { (opreation, error) -> Void in
... // Error handler
}
}
將組件間的耦合降到最低蚜厉。另外注意這段代碼中對(duì)返回結(jié)果的解析已經(jīng)移到DataSource
了。
減少耦合
正如上面提到的做法畜眨,之前諸多對(duì)DataSource
直接的操作昼牛,在這一版中有許多都改為了通過(guò)Notification交互:
init(){
let cityComponents = getCityListFromFile()
... // Data initialization
NSNotificationCenter.defaultCenter().addObserver(self, #selector(CMDataSource.didSetStart()), name: PropertyKeys.kCMSetStartStationNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, #selector(CMDataSource.didSetEnd()), name: PropertyKeys.kCMSetEndStationNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, #selector(CMDataSource.didRemoveStart()), name: PropertyKeys.kCMRemoveStartNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, #selector(CMDataSource.didRemoveEnd()), name: PropertyKeys.kCMRemoveEndNotification, object: nil)
}
deinit{
NSNotificationCenter.defaultCenter().removeObserver(self)
}
其他
對(duì)于與上面談到的改進(jìn)點(diǎn)比較接近的頁(yè)面术瓮,限于篇幅不再贅述,只在此處一并提及:
-
地圖View
贰健,去掉了與上層的所有耦合胞四,交互改為發(fā)NSNotification
;同時(shí)將地圖上要顯示的控件統(tǒng)一到一個(gè)protocol
之下伶椿,這樣就可以利用Swift新提供的泛型
特性辜伟,寫出很強(qiáng)一致性的代碼:func updateCoor<T: TiledScrollViewOverlay where T: UIView>(_view: T, zoom: Bool){ ... // Update code }
網(wǎng)絡(luò)請(qǐng)求模塊
,上面已有提及脊另,在移除不屬于自身邏輯的同時(shí)游昼,重新整理了解析數(shù)據(jù)的時(shí)機(jī)(原來(lái)的版本中居然重復(fù)著極為相似的三段代碼!又一個(gè)車禍現(xiàn)場(chǎng))尝蠕;-
分享模塊
烘豌,之前的封裝使用非常繁瑣,每次還要自行加到UIWindow上去看彼。于是又重新寫了一個(gè)廊佩,彈出分享控件只需下面幾步:let shareKit: CMShareKit = CMShareKit.getInstance() shareKit.setup("分享文字", emailBody: "郵件文字", smsBody: shareContent, weixinTitle: appName, weixinBody: shareContent, weixinPengyouBody: shareContent, weixinURL: CMConfig.AppDownloadURLTrim.absoluteString, qqTitle: appName, qqBody: shareContent, qqURL: CMConfig.AppDownloadURLTrim.absoluteString, weiboBody: shareContent, image: self.getCurrentViewShot()) shareKit.show(self)
后來(lái)看到Matt Gemmell在他的《API Design》這篇blog中也不謀而合地提到了這一點(diǎn):
Rule 6: Get up and running in 3 lines
Excluding delegate methods, you should aim to make it usable at least for testing purposes with only 3 lines of code.
Those lines are:Instantiate it.
Basically configure, so it will show and/or do something.
Display or otherwise activate it.That should be it. Anything substantially more onerous is a code smell.
-
添加/刪除書簽和歷史模塊
,干掉靖榕,完全移入DataSource
标锄。并且前一版本是把數(shù)據(jù)序列化后保存為文件,這導(dǎo)致了相當(dāng)大的性能問(wèn)題茁计,在暴力測(cè)試瘋狂增刪時(shí)甚至可能導(dǎo)致崩潰料皇。新版中改為只保存必要的信息(比如起點(diǎn)和終點(diǎn)),等需要時(shí)再把路線實(shí)時(shí)計(jì)算出來(lái)即可星压。
一些私心
上面基本把大塊的重構(gòu)頁(yè)面講完了践剂,下面說(shuō)幾個(gè)舊版本中沒(méi)有的頁(yè)面,也算是我自己的私心娜膘。
考慮到這是我最后一次負(fù)責(zé)這個(gè)項(xiàng)目逊脯,也是第一次有如此大的決定權(quán),我不禁在重構(gòu)之余騰出手來(lái)加了一些自己想加的頁(yè)面竣贪。
致謝頁(yè)面
作為一個(gè)有點(diǎn)情懷的程序員军洼,我一直對(duì)于程序中沒(méi)有對(duì)開源庫(kù)的致謝這一點(diǎn)耿耿于懷。以前對(duì)項(xiàng)目沒(méi)有話語(yǔ)權(quán)演怎,也騰不出手做匕争,這次怎么也得補(bǔ)上:
注:由于當(dāng)時(shí)在發(fā)布時(shí)CocoaPods和Swift還有些兼容性問(wèn)題,所以沒(méi)有用CocoaPods自動(dòng)生成的爷耀,而是自己寫了個(gè)HTML網(wǎng)頁(yè)甘桑。
關(guān)于頁(yè)
關(guān)于頁(yè)也是夙愿之一。事實(shí)上更早的版本中有這個(gè)頁(yè)面,但由于一些不可考的原因給砍掉了扇住。
我仿照之前的關(guān)于頁(yè)用Photoshop作了個(gè)圖,并且順便把以前版本的關(guān)于頁(yè)也加了進(jìn)來(lái)盗胀,往右邊滑動(dòng)即可看到艘蹋。雖然這么個(gè)深藏在n級(jí)頁(yè)面之下的頁(yè)面估計(jì)沒(méi)多少人點(diǎn)進(jìn)來(lái),但總算還是為以前那些已經(jīng)離職的曾經(jīng)為地鐵通出過(guò)力的同事在這個(gè)角落立了個(gè)碑票灰。
歷史版本
這個(gè)點(diǎn)子我是在做好線路詳情頁(yè)的時(shí)候想到的:既然我做了一套可以在cell上畫線路的代碼女阀,那么我可以把它復(fù)用到別的地方啊——比如整個(gè)地鐵通的發(fā)展史!
于是就有了下面的頁(yè)面:
看起來(lái)很簡(jiǎn)單屑迂,其實(shí)做起來(lái)還是有點(diǎn)麻煩的浸策。我在元旦跨年的時(shí)候去了趟日本休年假,途中帶上了筆記本電腦惹盼,那真是一有時(shí)間就在做這個(gè)頁(yè)面啊……
末班車提示
這也是我私心特別想做的功能庸汗,也借著這次機(jī)會(huì)做了出來(lái):
上個(gè)動(dòng)圖演示:
對(duì)于地鐵線路而言,首班車很簡(jiǎn)單——只需從數(shù)據(jù)庫(kù)中讀取起點(diǎn)站的首班車信息即可手报◎遣眨可末班車卻復(fù)雜得多,它既不是簡(jiǎn)單的起點(diǎn)站末班車時(shí)間掩蛤,也不是最后一個(gè)換乘站的末班車時(shí)間枉昏,而是有點(diǎn)類似于木桶原理,取決于短板——也就是說(shuō)揍鸟,取決于整條換乘路徑中收班最早的換乘站兄裂。
在之前的版本中,我們實(shí)現(xiàn)了這個(gè)算法阳藻,但不時(shí)有用戶表示疑惑:這個(gè)末班車是怎么算出來(lái)的晰奖?為什么這么詭異?
上面的這個(gè)功能就是對(duì)這個(gè)問(wèn)題的優(yōu)雅解答腥泥,一切都是自解釋的:在用戶操作的時(shí)候就能注意到變化畅涂,一句多余的說(shuō)明都不用。
只要趕上標(biāo)紅字的站點(diǎn)的末班車道川,整條末班車線路就能成立午衰。
事實(shí)上,之前的產(chǎn)品經(jīng)理曾經(jīng)做出過(guò)一版設(shè)計(jì):
我不喜歡這個(gè)設(shè)計(jì)冒萄,因?yàn)樗盍蚜松舷挛碾丁棿笆莻€(gè)十分影響交互體驗(yàn)的設(shè)計(jì)模式,除非你一定要顯式地引起用戶注意尊流,否則引入彈窗就是錯(cuò)誤的帅戒。電腦出棧入棧尚且有開銷,何況人腦?(另外注意這個(gè)頁(yè)面里的“首班車”和“末班車”按鈕——沒(méi)有比這倆按鈕更突兀的設(shè)計(jì)了)
后來(lái)這個(gè)功能在反對(duì)聲中索性被砍掉了逻住,但我還是覺得這個(gè)需求是有必要的钟哥,只是需要找到更合理的設(shè)計(jì)。
在找到現(xiàn)在這個(gè)解決方案之前也糾結(jié)了很久瞎访,最終在某個(gè)凌晨能很快實(shí)現(xiàn)還是要?dú)w功于這次合理的架構(gòu)設(shè)計(jì)腻贰,很難想象在之前那一坨3000行的代碼里加個(gè)這種功能要抓狂到什么地步。
彩蛋
是的扒秸,作為一個(gè)有著不止一點(diǎn)情懷的程序員……我還埋了個(gè)彩蛋播演,估計(jì)沒(méi)人發(fā)現(xiàn)過(guò),即使如此我也不想說(shuō)xD
PS. 那些大公司的軟件里彩蛋到底是怎么埋的伴奥?一Code Review不是就直接被辭退了嗎写烤?
未竟事業(yè)
事實(shí)上,我還抽空稍微規(guī)劃了一下iPad版本的地鐵通:
但由于時(shí)間實(shí)在是不夠用拾徙,最終作罷洲炊。
跋
基本寫完了程序主體,然后我又開始做起了市場(chǎng)尼啡。
當(dāng)時(shí)趕工時(shí)每天唯一的娛樂(lè)是伴飯看一集《廣告狂人》选浑,所以到這個(gè)想廣告詞的環(huán)節(jié)覺得特別帶感,感覺Don Draper(《廣告狂人》男主角)上身玄叠。
于是就有了我用Photoshop做的下面這一批AppStore宣傳圖:
扶上驢古徒,送一程。至此读恃,對(duì)于這個(gè)項(xiàng)目隧膘,我的任務(wù)差不多完成了。
用戶反饋差不多是這樣:
就用項(xiàng)目剛開始時(shí)我在豆瓣發(fā)的一條廣播來(lái)結(jié)束本文吧:
希望能給你帶來(lái)一些啟發(fā)寺惫,歡迎提出意見和批評(píng)疹吃。
感謝觀賞。
廣告:
我現(xiàn)在正在找工作西雀,希望加入一個(gè)比較大的團(tuán)隊(duì)繼續(xù)學(xué)習(xí)萨驶。
如果您覺得我比較合適,可以聯(lián)系:zshowing[at]gmail.com