开源项目——『看知乎』iOS 版

3,716 阅读10分钟

前言

前段时间无意中发现了看知乎,一个知乎答案和用户的精选站。网站开发者是知乎用户苏莉安,他写了个爬虫从知乎抓取数据,而且还提供了 API 文档。我大致看了下文档,感觉写个 iOS 客户端应该也挺不错的,于是就开始写了。

因为是个人项目,主要目的还是为了练手,所以我没有用任何第三方类库。网络请求、JSON 解析、异步图片加载等等全都是自己封装的,UI 布局主要是用 Storyboard 跟 AutoLayout 做的,开发语言采用 Swift。目前已经完成了大部分内容,花的时间不长,后续我还会添加一些功能,然后做一些优化,再加点注释。由于时间仓促,我也没有写测试用例,整个项目目前肯定还有很多不足的地方,有朋友发现什么 Bug 的话也欢迎留言告诉我。我在这边准备大概展示一下项目,然后挑几个我觉得比较值得讲的点讲一下。相信对大家多少应该有些帮助。

实现功能

文章推荐:

「看知乎」的答案推荐以文章为单位,每天在三个时段发布三篇,名字分别为昨日最新(yesterday)、近日热门(recent)和历史精华(archive),每篇推荐32~40个答案不等

客户端接受最近10篇推荐,点击单篇推荐会转到相应的答案列表,点击单个答案会转到相应的答案详情。

用户排名:

获取某项指标(赞同数、粉丝数)排名前30的用户列表,点击单个用户转到该用户详情页。

用户详情页(显示效果模仿简书个人用户界面)显示用户近期动态和高票答案,点击具体答案转到答案详情页。更多内容有待添加。

用户搜索,输入用户名或部分用户名直接搜索,搜索结果显示相关用户列表,点击单个用户转到该用户详情页。

项目展示


首页.gif


首页答案列表.gif


答案详情.gif


用户排行.gif


用户详情.gif


用户回答.gif


用户搜索.gif


排名方式.gif


项目结构.png

项目主要是分为两大模块,即首页模块(Home)和用户模块(TopUsers)。Global 目录中是我自己封装的几个简单类库和一些常量。

几个 Tips

用 Storyboard 快速设置 layer 层的属性


label.png

设置圆角、边框等属性是日常开发中几乎每天都要做的事情,譬如我们现在要实现如上这个带边框和圆角的 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 上方便的设置这几个属性了。

实现简书式的用户个人页面

我的用户详情页面是模仿简书写的,总的来说就是头像会随页面上滑缩小(初始状态是半个头像在导航栏中,最后整个头像都到导航栏中),然后菜单项会停留在导航栏下方,点击菜单项,下面的 Cell 会显示相应的数据。

头像的缩放主要是改变宽高的约束和边角半径的大小(要使一个正方形变成圆形只需将其边角半径 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 拉过来的头像的宽高的约束。

至于点击菜单项显示不同数据的效果呢,乍一看跟我之前写过的多表视图有点像,但那个思路在这边是不太行得通的,因为列表上面的内容(菜单项、用户基本信息)都得进行滚动,如果按那个思路的话,同一维度(y 轴方向)我们要处理两个 TableView(或者一个 ScrollView 一个 TableView) 的滚动,这是不科学的。

所以这里我只用了一个 TableView,当选择不同的菜单项的时候,使用不同的数据源(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 是自己定义的一个委托协议:

protocol UserMenuDelegate: class {
    func selectMenuItem(item: UserMenuItem)
}

Controller 实现这个协议,就可以获知点击了哪个菜单项,从而给 TableView 配置相应的数据源,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 是个非常经典的概念,它最早来自于 SmallTalk,四人帮的《设计模式》在引言中就介绍了 MVC——通过“订阅/通知”协议来分离 Model 和 View;View 使用 Controller 子类的实例来实现一个特定的响应策略。显然 SmallTalk 中的 MVC 是以 View 为中心的,Model 跟 Controller 原本都可以是 View 的一部分,只不过现在把数据部分分离出去成为 Model,把处理响应的逻辑分离出去作为 Controller。是不是觉得这跟你认识的 MVC 完全不一样?因为不知道什么时候起,有人认为 MVC 应该是由 Controller 作为 Model 和 View 的中介,Model 和 View 是不能通信的。于是 Controller 成了 MVC 的中心,这种思想也是 iOS 开发中的主流思想,斯坦福 iOS 公开课上白胡子老头放过一张解释 MVC 的图:


主流 MVC.png

从这张图中就可以看出 Controller 要做的事情实在太多了,如果是手写 UI 的话,还要在 Controller 中写很多布局相关的代码,非常难以维护。05年的时候微软为设计 WPF 而提出 MVVM 模式,主要思想是基于Model 和 View 的数据双向绑定,通过响应事件来处理用户的操作。于是有人提出在 iOS 中使用 MVVM,不过 Cocoa Touch 跟 WPF 是不一样的,所以大多数时候在 iOS 中的 MVVM 其实是 M-VM-V-C,也就是在 View 和 Model 之间加了个 ViewModel 用来处理数据绑定,目的主要就是给 Controller 分担点压力。

我觉得架构这方面来说,iOS 开发中最主要的矛盾其实就一个,Controller 的负担太重。所以我们其实不必执着于各种说法,只要想想目前我们的 Controller 都做了些什么:

  • UI 布局
  • 协调各个 View
  • 协调 View 和 Model
  • 处理 View 的响应
    ……

我们再来看看哪些是可以从 Controller 分离出来的:

  • UI 布局可以用 Storyboard 或者 Xib 做,要用纯代码写也最好用子类来定制某个视图的外观,组合视图的话用一个 UIView 的子类封装起来,不要在 Controller 去设置一堆 label 啊 button 啊然后各种 addSubview。
  • View 和 Model 之间的数据绑定,可以在 View 中设置一个以 Model 为参数的方法,Controller 中只要调用这个方法即可,具体的绑定逻辑写在 View 中。
  • TableView 的数据源如果只有一个,可以让 Controller 充当,如果有好多个,那就单独定义,然后将其实例组合到 Controller 中。
  • View 的响应,如果是 UI 相关的,譬如改变颜色位置大小等等,都可以放到 View 中自己搞定,但是一些数据相关的,或者需要跟其他 View 协调的,可以通过代理让 Controller 去处理。

我以『看知乎』项目中的代码为例来说明一下我自己比较喜欢的做法。首先,UI 布局全用 Storyboard 做,这样少了布局的代码,View 就很空了,然后定义一个 ViewModelType 协议:

protocol ViewModelType {
    typealias ModelType
    func bindModel(model: ModelType)
}

Swift 中没有范型协议,不能直接写protocol ViewModelType,不过通过typealias限定参数类型的方式,也能达到范型协议的效果。

接下来,我们有一个 TopAnswerCell,已经用 Storyboard 布局完毕,把要用到的几个 View 的 outlet 拉到代码中,然后实现 ViewModelType 协议:

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 的例子,至于处理响应的例子我之前已经举过了,就是模仿简书用户页面里用到的 UserMenu 的例子,点击菜单项后变色指示器滑动等操作都在 UserMenu 内部完成,而要跟 TableView 交互的部分则放到 Controller 中。多个数据源的情况上面也提过了,点击不同的菜单项就使用不同的数据源。

关于面向协议编程

Swift2之后可以用 extension 给协议方法或者属性加上一个默认实现了,这使得 Swift 可以用协议模拟 Ruby 中用 module 实现的 mixin 效果,也就是通过协议扩展某个类的功能。譬如我自定义了一个 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秒后完成刷新。

我再定义一个协议:

protocol Refreshable: class {
    func getData()
    var simpleRefreshControl: SimpleRefreshControl { get }
}

extension Refreshable {
    var simpleRefreshControl: SimpleRefreshControl {
        return SimpleRefreshControl { [weak self] in
            self?.getData()
        }
    }
}

这样如果我有好几个 TableViewController 都要实现刷新功能,只要都实现Refreshable协议,然后定义各自的getData方法,再在 ViewDidLoad 中加上refreshControl = simpleRefreshControl这一句就行了。如果不使用这个协议,你就不得不重复写好多遍如下代码

SimpleRefreshControl { [weak self] in
    self?.getData()
}

这个例子代码不多,可能效果不是很明显。然而只要擅用这个技巧,绝对可以让你的代码精简很多,而且更加灵活,可读性也更高。

JSON Mapper

我自己实现了一个简陋的 JSON-Model Mapper,并不完善,不建议用在正式项目中,有兴趣的同学可以看看思路

最后

其实还有一些想说的,但是篇幅已经太长了,而且现在也好晚了,所以具体的还是请大家自己看代码吧。

下载完整项目源码

觉得有用的话麻烦 Star 一个~有问题欢迎留言交流^ ^