美文网首页iOS 砖家纪实录swift干货iOS开发
开源项目——『看知乎』iOS 版

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

作者: Sheepy | 来源:发表于2016-01-27 03:13 被阅读5880次

    前言

    前段时间无意中发现了看知乎,一个知乎答案和用户的精选站。网站开发者是知乎用户苏莉安,他写了个爬虫从知乎抓取数据,而且还提供了 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<T>,不过通过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 一个~有问题欢迎留言交流^ ^

    相关文章

      网友评论

      • edb60c6a1b4a:为什么在真机上运行后,数据列表都是空的呢?
      • smile丽语:很有用,Mark :smile:
      • oo上海:非常感谢分享,认真地看了源代码。

        我主要冲着JSON Parse看的,看的时候有点疑惑啊,开动了我智慧的小脑筋也算看了半天,结果后来发现你有一篇文章写了思路呀!!!

        建议可以把相关文章的链接也放在这边

        感恩开源 :smile: :pray:
        Sheepy:@oo上海 嗯,多谢支持~主要我当时觉得我那个 JSON Mapper 实在是很简陋,我并没有严格地去检查类型什么的,是不建议用在正式项目中的 :joy: 不过看看思路,知道能这么玩,还是可以的,我这就加上链接吧……
      • e20c21e26d6d:唯一的缺点就是popover出来的东西并不在合适的位置。。看上去不太和谐。。不知道楼主有没有办法能够让这个弹窗下来一点。。
      • FindCrt:1.MVVM那里说的很好,已赞
        2.MVVM举例是举的cell使用的例子,简单说,就是把一个数据单元丢个了cell,至于数据如何使用、如何呈现都是cell自己去管理,是吧?这个我一直在用,不过因为tableView的data source委托方法我都是直接写在ViewController里的,所以构建cell也是ViewController里。如果写在ViewController里,是否还是MVVM的思想呢?这时(1)代码的位置变了 (2)但是cell和数据之间的直接接触没变
        3.定义文Refreshable协议以及给协议添加extension的地方,你是为了避免重复写self.getData()方法,但其实可以把getData()的内容直接写在SimpleRefreshControl的init方法里。给协议添加方法实现倒是挺有意思的东西,这样协议又定义方法、又实现方法,感觉直接变成多继承去了,好像挺吊的样子,可能还有其他特殊的使用。
        Sheepy:@find_1991 这跟多继承虽然像,但是比多继承干净,每个协议只专注一个功能,实现了这个协议,就获得了这个功能,而多继承的话,继承的都是类,可能造成很长的继承链,容易引入其他不必要的逻辑和依赖,造成错综复杂的类关系。
        Sheepy:@find_1991 定义 Refreshable 协议当然不是为了避免重复写 getData 方法,getData 方法是在 Controller 里定义的,每个 Controller 的 getData 方法都不一样,怎么写在 init 方法里。这里是借鉴了 Ruby 中 mixin 的思想,实现了 Refreshable 协议之后就自动拥有了一个 simpleRefreshControl 属性,不需要手动去实例化一个 SimpleRefreshControl ,也不用去绑定刷新逻辑。
        Sheepy:@find_1991 现在 UITableViewController 默认就是实现 UITableViewDataSource 协议的,一般我也只是用 extension 把委托方法跟其他代码分割开而已。如果同一个 Tableview 有好几个 Data source 的话我才会用其他类来实现。我这边说的 MVVM 只要把绑定数据的逻辑分离到 ViewModel 中就行了。
      • 狸小猫:一直以为双鱼女是个感性小鸟依人的小女人……原来也存在高智商的大神……🤓
        狸小猫:@Sheepy 我一直以为你是妹子!!!!
        狸小猫:@Sheepy 我去!!!!!哎哟……
        Sheepy:@狸小猫 可是我是双鱼男啊……你难道一直当我是姑娘么:joy::joy::joy:
      • dfc936d0465a:大神,我正在学ios swift开发,只是个人爱好,求教一下,该如何入门和之后的深入学习?
        Sheepy:@dfc936d0465a 业余玩玩的话,先看看官方文档《The Swift Programming Language》,同时看看斯坦福白胡子老头的公开课,网上就有免费的视频,跟着敲里面的 Demo。然后找个简单的 App 实践下,遇到问题查文档查 Google 查 StackOverflow。如果只是想做点东西玩玩,那就再找些实战方面的书看,多实践。如果是真想好好学编程么,去补好计算机基础,计算机系统、算法与数据结构、数据库、网络这些。
      • Scott_Mr:链接连接不上去耶
        Scott_Mr:@Sheepy 这样哈
        Sheepy:今天 Github 挂了 全世界码农都在等官方解决 :pensive:
      • 00fe1d42e006:这篇文章还真长~
        Sheepy:@迷糊小小姐 强迫症了,不写完不想睡,其实困死了
        00fe1d42e006:@Sheepy 凌晨三点 也真是文思泉涌:relieved:
        Sheepy::relieved:是的啊,写老半天
      • zmj27404:膜拜一下!!! :pray:
      • smalldu:叼叼叼
        Sheepy:@大石头布 :joy:

      本文标题:开源项目——『看知乎』iOS 版

      本文链接:https://www.haomeiwen.com/subject/qnudkttx.html