前言
前段时间无意中发现了看知乎,一个知乎答案和用户的精选站。网站开发者是知乎用户苏莉安,他写了个爬虫从知乎抓取数据,而且还提供了 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 上:
因为我把这几个属性扩展到了 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
}
这边的avatarHeight
和avatarWidth
是从 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 一个~有问题欢迎留言交流^ ^
网友评论
我主要冲着JSON Parse看的,看的时候有点疑惑啊,开动了我智慧的小脑筋也算看了半天,结果后来发现你有一篇文章写了思路呀!!!
建议可以把相关文章的链接也放在这边
感恩开源
2.MVVM举例是举的cell使用的例子,简单说,就是把一个数据单元丢个了cell,至于数据如何使用、如何呈现都是cell自己去管理,是吧?这个我一直在用,不过因为tableView的data source委托方法我都是直接写在ViewController里的,所以构建cell也是ViewController里。如果写在ViewController里,是否还是MVVM的思想呢?这时(1)代码的位置变了 (2)但是cell和数据之间的直接接触没变
3.定义文Refreshable协议以及给协议添加extension的地方,你是为了避免重复写self.getData()方法,但其实可以把getData()的内容直接写在SimpleRefreshControl的init方法里。给协议添加方法实现倒是挺有意思的东西,这样协议又定义方法、又实现方法,感觉直接变成多继承去了,好像挺吊的样子,可能还有其他特殊的使用。