開源項目——『看知乎』iOS 版

前言

前段時間無意中發(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)和高票答案钥勋,點擊具體答案轉到答案詳情頁炬转。更多內容有待添加辆苔。

用戶搜索,輸入用戶名或部分用戶名直接搜索扼劈,搜索結果顯示相關用戶列表驻啤,點擊單個用戶轉到該用戶詳情頁。

項目展示

首頁.gif
首頁答案列表.gif
答案詳情.gif
用戶排行.gif
用戶詳情.gif
用戶回答.gif
用戶搜索.gif
排名方式.gif
項目結構.png

項目主要是分為兩大模塊荐吵,即首頁模塊(Home)和用戶模塊(TopUsers)骑冗。Global 目錄中是我自己封裝的幾個簡單類庫和一些常量。

幾個 Tips

用 Storyboard 快速設置 layer 層的屬性

label.png

設置圓角先煎、邊框等屬性是日常開發(fā)中幾乎每天都要做的事情沐旨,譬如我們現(xiàn)在要實現(xiàn)如上這個帶邊框和圓角的 label,用代碼我們可以這么寫:

label.layer.cornerRadius = xxx
label.layer.borderColor = xxx
label.layer.borderWidth = xxx

但如果你是用 Storyboard(Storyboard 其實是個 xml 文件) 做布局的榨婆,你可能無法再容忍在你的邏輯代碼中混入布局相關的代碼磁携,那用 Storyboard 怎么做呢?比較直接的是利用 Runtime:

Runtime Attributes.png

你可以在上面這個地方自己添加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 上:

圓角 label.png

因為我把這幾個屬性擴展到了 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
}

這邊的avatarHeightavatarWidth是從 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 的圖:

主流 MVC.png

從這張圖中就可以看出 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 一個~有問題歡迎留言交流^ ^

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末莹弊,一起剝皮案震驚了整個濱河市涤久,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌忍弛,老刑警劉巖拴竹,帶你破解...
    沈念sama閱讀 206,013評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異剧罩,居然都是意外死亡栓拜,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,205評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來幕与,“玉大人挑势,你說我怎么就攤上這事±裁” “怎么了潮饱?”我有些...
    開封第一講書人閱讀 152,370評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長诫给。 經(jīng)常有香客問我香拉,道長,這世上最難降的妖魔是什么中狂? 我笑而不...
    開封第一講書人閱讀 55,168評論 1 278
  • 正文 為了忘掉前任凫碌,我火速辦了婚禮,結果婚禮上胃榕,老公的妹妹穿的比我還像新娘盛险。我一直安慰自己,他們只是感情好勋又,可當我...
    茶點故事閱讀 64,153評論 5 371
  • 文/花漫 我一把揭開白布苦掘。 她就那樣靜靜地躺著,像睡著了一般楔壤。 火紅的嫁衣襯著肌膚如雪鹤啡。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 48,954評論 1 283
  • 那天蹲嚣,我揣著相機與錄音递瑰,去河邊找鬼。 笑死端铛,一個胖子當著我的面吹牛泣矛,可吹牛的內容都是我干的。 我是一名探鬼主播禾蚕,決...
    沈念sama閱讀 38,271評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼您朽,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了换淆?” 一聲冷哼從身側響起哗总,我...
    開封第一講書人閱讀 36,916評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎倍试,沒想到半個月后讯屈,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,382評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡县习,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,877評論 2 323
  • 正文 我和宋清朗相戀三年涮母,在試婚紗的時候發(fā)現(xiàn)自己被綠了谆趾。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 37,989評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡叛本,死狀恐怖沪蓬,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情来候,我是刑警寧澤跷叉,帶...
    沈念sama閱讀 33,624評論 4 322
  • 正文 年R本政府宣布,位于F島的核電站营搅,受9級特大地震影響云挟,放射性物質發(fā)生泄漏。R本人自食惡果不足惜转质,卻給世界環(huán)境...
    茶點故事閱讀 39,209評論 3 307
  • 文/蒙蒙 一园欣、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧峭拘,春花似錦俊庇、人聲如沸狮暑。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,199評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽搬男。三九已至拣展,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間缔逛,已是汗流浹背备埃。 一陣腳步聲響...
    開封第一講書人閱讀 31,418評論 1 260
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留褐奴,地道東北人按脚。 一個月前我還...
    沈念sama閱讀 45,401評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像敦冬,于是被迫代替她去往敵國和親辅搬。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,700評論 2 345

推薦閱讀更多精彩內容

  • 功能簡介功能說明:有道云筆記脖旱,主打云筆記堪遂,云協(xié)作功能筆記類競品:印象筆記、為知筆記協(xié)作類競品:石墨文檔萌庆、Teamb...
    花名古月閱讀 4,886評論 2 13
  • 以為吹菱,一個可怕的思想瞬間。 總以為理想的大學彭则,并不是真正讀的大學毁葱。每個人的想法都很美好,很豐滿贰剥,其實倾剿,他們忽視了現(xiàn)...
    攀枝花學院閱讀 197評論 0 0
  • 001 最了解自己的還是我們自己,有疑惑的時候除了咨詢專家還是要聆聽自己的內心蚌成。這還關系到是什么問題前痘,如果是看病,...
    鹿蕾閱讀 89評論 0 1