前言
前段時間無意中發(fā)現(xiàn)了看知乎演侯,一個知乎答案和用戶的精選站。網(wǎng)站開發(fā)者是知乎用戶蘇莉安伐憾,他寫了個爬蟲從知乎抓取數(shù)據(jù)勉痴,而且還提供了 API 文檔。我大致看了下文檔策精,感覺寫個 iOS 客戶端應該也挺不錯的淘钟,于是就開始寫了柿汛。
因為是個人項目,主要目的還是為了練手雏掠,所以我沒有用任何第三方類庫。網(wǎng)絡請求劣像、JSON 解析乡话、異步圖片加載等等全都是自己封裝的,UI 布局主要是用 Storyboard 跟 AutoLayout 做的耳奕,開發(fā)語言采用 Swift绑青。目前已經(jīng)完成了大部分內容,花的時間不長屋群,后續(xù)我還會添加一些功能闸婴,然后做一些優(yōu)化,再加點注釋芍躏。由于時間倉促掠拳,我也沒有寫測試用例,整個項目目前肯定還有很多不足的地方纸肉,有朋友發(fā)現(xiàn)什么 Bug 的話也歡迎留言告訴我溺欧。我在這邊準備大概展示一下項目,然后挑幾個我覺得比較值得講的點講一下柏肪。相信對大家多少應該有些幫助姐刁。
實現(xiàn)功能
文章推薦:
「看知乎」的答案推薦以文章為單位,每天在三個時段發(fā)布三篇烦味,名字分別為昨日最新(yesterday)聂使、近日熱門(recent)和歷史精華(archive),每篇推薦32~40個答案不等
客戶端接受最近10篇推薦谬俄,點擊單篇推薦會轉到相應的答案列表柏靶,點擊單個答案會轉到相應的答案詳情。
用戶排名:
獲取某項指標(贊同數(shù)溃论、粉絲數(shù))排名前30的用戶列表屎蜓,點擊單個用戶轉到該用戶詳情頁。
用戶詳情頁(顯示效果模仿簡書個人用戶界面)顯示用戶近期動態(tài)和高票答案钥勋,點擊具體答案轉到答案詳情頁炬转。更多內容有待添加辆苔。
用戶搜索,輸入用戶名或部分用戶名直接搜索扼劈,搜索結果顯示相關用戶列表驻啤,點擊單個用戶轉到該用戶詳情頁。
項目展示
項目主要是分為兩大模塊荐吵,即首頁模塊(Home)和用戶模塊(TopUsers)骑冗。Global 目錄中是我自己封裝的幾個簡單類庫和一些常量。
幾個 Tips
用 Storyboard 快速設置 layer 層的屬性
設置圓角先煎、邊框等屬性是日常開發(fā)中幾乎每天都要做的事情沐旨,譬如我們現(xiàn)在要實現(xiàn)如上這個帶邊框和圓角的 label,用代碼我們可以這么寫:
label.layer.cornerRadius = xxx
label.layer.borderColor = xxx
label.layer.borderWidth = xxx
但如果你是用 Storyboard(Storyboard 其實是個 xml 文件) 做布局的榨婆,你可能無法再容忍在你的邏輯代碼中混入布局相關的代碼磁携,那用 Storyboard 怎么做呢?比較直接的是利用 Runtime:
你可以在上面這個地方自己添加layer.cornerRadius
等屬性良风,設置相應的 Type 和 Value谊迄。但是這個方法有兩個弊端,一是沒有自動提示烟央,輸入屬性名的時候容易輸錯统诺,二是layer.borderColor
這個屬性需要的 Type 是CGColor,但這里卻只能設置 UIColor疑俭,所以layer.borderColor
這個屬性是不能生效的粮呢。
最好的辦法是利用extension
和@IBInspectable
來做:
extension UIView {
@IBInspectable var cornerRadius: CGFloat {
set {
layer.cornerRadius = newValue
layer.masksToBounds = newValue > 0
}
get {
return layer.cornerRadius
}
}
@IBInspectable var borderWidth: CGFloat {
set {
layer.borderWidth = newValue
}
get {
return layer.borderWidth
}
}
@IBInspectable var borderColor: UIColor? {
set {
layer.borderColor = newValue?.CGColor
}
get {
return layer.borderColor != nil ? UIColor(CGColor: layer.borderColor!) : nil
}
}
}
標記為@IBInspectable
的屬性會顯示在 Storyboard 上:
因為我把這幾個屬性擴展到了 UIView 上,所以所有繼承自 UIView 的控件都可以在 Storyboard 上方便的設置這幾個屬性了钞艇。
實現(xiàn)簡書式的用戶個人頁面
我的用戶詳情頁面是模仿簡書寫的啄寡,總的來說就是頭像會隨頁面上滑縮小(初始狀態(tài)是半個頭像在導航欄中哩照,最后整個頭像都到導航欄中)挺物,然后菜單項會停留在導航欄下方,點擊菜單項飘弧,下面的 Cell 會顯示相應的數(shù)據(jù)识藤。
頭像的縮放主要是改變寬高的約束和邊角半徑的大小(要使一個正方形變成圓形只需將其邊角半徑 cornerRadius 設置成邊長的一半大小即可):
//頭像隨頁面滑動改變大小
func scrollViewDidScroll(scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let headerHeight = tableHeader.frame.height
guard offsetY < headerHeight else {
avatarHeight.constant = avatarMaxRadius/2
avatarWidth.constant = avatarMaxRadius/2
avatarImageView.cornerRadius = avatarMaxCornerRadius/2
return
}
let multiplier = offsetY/headerHeight
//外接矩形最終長寬都減一半
avatarHeight.constant = avatarMaxRadius - avatarMaxRadius/2 * multiplier
avatarWidth.constant = avatarHeight.constant
layoutAvatarImmediately()
//圓角半徑最終減一半
avatarImageView.cornerRadius = avatarMaxCornerRadius - avatarMaxCornerRadius/2 * multiplier
}
func layoutAvatarImmediately() {
avatarHeight.active = true
avatarWidth.active = true
}
這邊的avatarHeight
和avatarWidth
是從 Storyboard 拉過來的頭像的寬高的約束次伶。
至于點擊菜單項顯示不同數(shù)據(jù)的效果呢痴昧,乍一看跟我之前寫過的多表視圖有點像,但那個思路在這邊是不太行得通的冠王,因為列表上面的內容(菜單項赶撰、用戶基本信息)都得進行滾動,如果按那個思路的話,同一維度(y 軸方向)我們要處理兩個 TableView(或者一個 ScrollView 一個 TableView) 的滾動扣囊,這是不科學的。
所以這里我只用了一個 TableView绒疗,當選擇不同的菜單項的時候侵歇,使用不同的數(shù)據(jù)源(UITableViewDataSource):
lazy var userDynamicDataSource: UserDynamicDataSource = {
let dataSource = UserDynamicDataSource()
dataSource.userDynamicList = self.userDynamicList
dataSource.name = self.userInfo.name
dataSource.avatar = self.userInfo.avatar
return dataSource
}()
lazy var topAnswerDataSource: TopAnswerDataSource = {
let dataSource = TopAnswerDataSource()
dataSource.topAnswerList = self.topAnswerList
return dataSource
}()
對于點擊菜單項之后改變顏色移動指示器滑條這些 UI 操作我都放在了 UserMenu 中來做,然后把跟 TableView 交互的操作委托給 Controller 來做:
weak var delegate: UserMenuDelegate?
func addMenuItemTarget() {
[dynamicButton, answerButton, moreButton].forEach {
$0.addTarget(self, action: "selectMenuItem:", forControlEvents: .TouchUpInside)
}
}
func selectMenuItem(item: UIButton) {
//將選中的 item 設為選中色吓蘑,并將上一次選中的 item 恢復為未選中色
item.setTitleColor(selectedColor, forState: .Normal)
lastSelectedItem.setTitleColor(deselectedColor, forState: .Normal)
lastSelectedItem = item
//改變指示條的約束惕虑,使其水平中心點與選中 item 的水平中心點相同
let newCenterX = NSLayoutConstraint(item: indicator, attribute: .CenterX, relatedBy: .Equal, toItem: item, attribute: .CenterX, multiplier: 1, constant: 0)
indicatorCenterX.active = false
indicatorCenterX = newCenterX
indicatorCenterX.active = true
//通知代理(通過 tag 初始化對應的菜單類型)
delegate?.selectMenuItem(UserMenuItem(rawValue: item.tag)!)
}
UserMenuItem 是一個 enum,用來表示菜單項類型磨镶,它的 rawValue 跟幾個菜單項 Button 的 tag 一一對應溃蔫,也跟列表的 rowHeight對應:
enum UserMenuItem: Int {
// rawValue 對應列表的 rowHeight
case Dynamic = 100
case Answer = 80
case More = 0
}
這個 UserMenuDelegate 是自己定義的一個委托協(xié)議:
protocol UserMenuDelegate: class {
func selectMenuItem(item: UserMenuItem)
}
Controller 實現(xiàn)這個協(xié)議,就可以獲知點擊了哪個菜單項琳猫,從而給 TableView 配置相應的數(shù)據(jù)源伟叛,rowHeight 可以直接通過 rawValue 拿到:
// MARK: - UserMenuDelegate
extension UserDetailViewController: UserMenuDelegate {
func selectMenuItem(item: UserMenuItem) {
guard userInfo != nil else { return }
switch item {
case .Dynamic:
tableView.dataSource = userDynamicDataSource
tableView.separatorStyle = .None
case .Answer:
tableView.dataSource = topAnswerDataSource
tableView.separatorStyle = .SingleLine
case .More:
break
}
//通過菜單類型的 rawValue 取得列表的 rowHeight
tableView.rowHeight = CGFloat(item.rawValue)
tableView.reloadData()
}
}
也談談 MVC 和 MVVM
MVC 是個非常經(jīng)典的概念,它最早來自于 SmallTalk脐嫂,四人幫的《設計模式》在引言中就介紹了 MVC——通過“訂閱/通知”協(xié)議來分離 Model 和 View统刮;View 使用 Controller 子類的實例來實現(xiàn)一個特定的響應策略。顯然 SmallTalk 中的 MVC 是以 View 為中心的账千,Model 跟 Controller 原本都可以是 View 的一部分侥蒙,只不過現(xiàn)在把數(shù)據(jù)部分分離出去成為 Model,把處理響應的邏輯分離出去作為 Controller匀奏。是不是覺得這跟你認識的 MVC 完全不一樣?因為不知道什么時候起娃善,有人認為 MVC 應該是由 Controller 作為 Model 和 View 的中介论衍,Model 和 View 是不能通信的。于是 Controller 成了 MVC 的中心聚磺,這種思想也是 iOS 開發(fā)中的主流思想饲齐,斯坦福 iOS 公開課上白胡子老頭放過一張解釋 MVC 的圖:
從這張圖中就可以看出 Controller 要做的事情實在太多了,如果是手寫 UI 的話咧最,還要在 Controller 中寫很多布局相關的代碼捂人,非常難以維護。05年的時候微軟為設計 WPF 而提出 MVVM 模式矢沿,主要思想是基于Model 和 View 的數(shù)據(jù)雙向綁定滥搭,通過響應事件來處理用戶的操作。于是有人提出在 iOS 中使用 MVVM捣鲸,不過 Cocoa Touch 跟 WPF 是不一樣的瑟匆,所以大多數(shù)時候在 iOS 中的 MVVM 其實是 M-VM-V-C,也就是在 View 和 Model 之間加了個 ViewModel 用來處理數(shù)據(jù)綁定栽惶,目的主要就是給 Controller 分擔點壓力愁溜。
我覺得架構這方面來說疾嗅,iOS 開發(fā)中最主要的矛盾其實就一個,Controller 的負擔太重冕象。所以我們其實不必執(zhí)著于各種說法代承,只要想想目前我們的 Controller 都做了些什么:
- UI 布局
- 協(xié)調各個 View
- 協(xié)調 View 和 Model
- 處理 View 的響應
……
我們再來看看哪些是可以從 Controller 分離出來的:
- UI 布局可以用 Storyboard 或者 Xib 做,要用純代碼寫也最好用子類來定制某個視圖的外觀渐扮,組合視圖的話用一個 UIView 的子類封裝起來论悴,不要在 Controller 去設置一堆 label 啊 button 啊然后各種 addSubview。
- View 和 Model 之間的數(shù)據(jù)綁定墓律,可以在 View 中設置一個以 Model 為參數(shù)的方法膀估,Controller 中只要調用這個方法即可,具體的綁定邏輯寫在 View 中耻讽。
- TableView 的數(shù)據(jù)源如果只有一個察纯,可以讓 Controller 充當,如果有好多個针肥,那就單獨定義捐寥,然后將其實例組合到 Controller 中。
- View 的響應祖驱,如果是 UI 相關的握恳,譬如改變顏色位置大小等等,都可以放到 View 中自己搞定捺僻,但是一些數(shù)據(jù)相關的乡洼,或者需要跟其他 View 協(xié)調的,可以通過代理讓 Controller 去處理匕坯。
我以『看知乎』項目中的代碼為例來說明一下我自己比較喜歡的做法束昵。首先,UI 布局全用 Storyboard 做葛峻,這樣少了布局的代碼锹雏,View 就很空了,然后定義一個 ViewModelType 協(xié)議:
protocol ViewModelType {
typealias ModelType
func bindModel(model: ModelType)
}
Swift 中沒有范型協(xié)議术奖,不能直接寫protocol ViewModelType<T>
,不過通過typealias
限定參數(shù)類型的方式礁遵,也能達到范型協(xié)議的效果。
接下來采记,我們有一個 TopAnswerCell佣耐,已經(jīng)用 Storyboard 布局完畢,把要用到的幾個 View 的 outlet 拉到代碼中唧龄,然后實現(xiàn) ViewModelType 協(xié)議:
class TopAnswerCell: UITableViewCell, ViewModelType {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var agreeLabel: UILabel!
@IBOutlet weak var dateLabel: UILabel!
func bindModel(model: TopAnswerModel) {
titleLabel.text = model.title
agreeLabel.text = "\(model.agree)"
dateLabel.text = model.date
}
}
這樣我們在 TableViewDataSource 中只要直接調用 bindModel 就好了:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(CellReuseIdentifier.User) as! TopUserCell
let index = indexPath.row
cell.bindModel((cellModelList[index], index))
return cell
}
以上是處理 Model 跟 View 的例子兼砖,至于處理響應的例子我之前已經(jīng)舉過了,就是模仿簡書用戶頁面里用到的 UserMenu 的例子,點擊菜單項后變色指示器滑動等操作都在 UserMenu 內部完成讽挟,而要跟 TableView 交互的部分則放到 Controller 中懒叛。多個數(shù)據(jù)源的情況上面也提過了,點擊不同的菜單項就使用不同的數(shù)據(jù)源耽梅。
關于面向協(xié)議編程
Swift2之后可以用 extension 給協(xié)議方法或者屬性加上一個默認實現(xiàn)了薛窥,這使得 Swift 可以用協(xié)議模擬 Ruby 中用 module 實現(xiàn)的 mixin 效果,也就是通過協(xié)議擴展某個類的功能褐墅。譬如我自定義了一個 RefreshControl:
class SimpleRefreshControl: UIRefreshControl {
typealias Action = () -> ()
var action: Action!
init(action: Action) {
super.init()
self.action = action
self.addTarget(self, action: "refresh", forControlEvents: UIControlEvents.ValueChanged)
}
func refresh() {
self.action()
delay(seconds: 1) {
self.endRefreshing()
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
它的構造器接受一個閉包拆檬,在刷新的時候會調用這個閉包洪己,然后1秒后完成刷新妥凳。
我再定義一個協(xié)議:
protocol Refreshable: class {
func getData()
var simpleRefreshControl: SimpleRefreshControl { get }
}
extension Refreshable {
var simpleRefreshControl: SimpleRefreshControl {
return SimpleRefreshControl { [weak self] in
self?.getData()
}
}
}
這樣如果我有好幾個 TableViewController 都要實現(xiàn)刷新功能,只要都實現(xiàn)Refreshable
協(xié)議答捕,然后定義各自的getData
方法逝钥,再在 ViewDidLoad 中加上refreshControl = simpleRefreshControl
這一句就行了。如果不使用這個協(xié)議拱镐,你就不得不重復寫好多遍如下代碼
SimpleRefreshControl { [weak self] in
self?.getData()
}
這個例子代碼不多艘款,可能效果不是很明顯。然而只要擅用這個技巧沃琅,絕對可以讓你的代碼精簡很多哗咆,而且更加靈活,可讀性也更高益眉。
JSON Mapper
我自己實現(xiàn)了一個簡陋的 JSON-Model Mapper晌柬,并不完善,不建議用在正式項目中郭脂,有興趣的同學可以看看思路年碘。
最后
其實還有一些想說的,但是篇幅已經(jīng)太長了展鸡,而且現(xiàn)在也好晚了屿衅,所以具體的還是請大家自己看代碼吧。
覺得有用的話麻煩 Star 一個~有問題歡迎留言交流^ ^