0%

Sources 开发日记二:搜索

Code Reader 改名为 Sources,已经上架
App Store: http://itunes.apple.com/app/id1125732186。
同时 Sources 也在 Github 上开源了,地址是:https://github.com/vulgur/Sources。

这一篇写搜索 Repo 功能的实现。
输入 Repo name 关键字,返回搜索结果。说起来简单,细节其实还不少。

架构 & 第三方库

这个 App 我第一次尝试使用 MVVM 架构来实现。框架我选择的是 Bond ,之所以没选 ReactiveCocoa,原因很简单:怎么学也学不会……RAC4 的源代码我读了三四遍,Demo 我也看了几个,但是无奈资质愚笨,败在了如何使用 Action 和 UI binding 上面(RAC4 不像 RAC2,缺少了 UI binding 的 extension,需要自己实现,不过他们正在考虑把 Rex 加入到 RAC 中)。选择 Bond 的原因是看了这个教程 ,发现这个 framework 和 RAC 比较相似,但是使用起来更简单明了 ,作者也说了这是一个轻量级的 binding 框架(妈蛋,写这篇 blog 的时候发现 Bond 作者推荐 ReactiveKit,说是更快更高更强……过两天再折腾)

以下是项目中目前为止用到的第三方库,因为项目是用 Swift 写的,所以选用的库也都是 Swift 写的:

  • Alamofire
  • AlamofireObjectMapper
  • ObjectMapper
  • Kingfisher
  • Bond
  • EZLoadingActivity

Model

先说 Model。搜索结果是 Repo 的列表,而 Repo 又包含着 Owner,所以先写这两个 model。这两个类的属性就是参照 Github API 返回的 JSON 字段来设计。这里选用 ObjectMapper 作为 JSON 和 Model 对象的转换器。

Owner.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

import ObjectMapper

class Owner: Mappable {
var name: String?
var ownerId: String?
var avatarURLString: String?

required init?(_ map: Map) {

}

init() {

}
// Mappable
func mapping(map: Map) {
name <- map["login"]
ownerId <- map["id"]
avatarURLString <- map["avatar_url"]
}
}
Repo.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import ObjectMapper

class Repo: Mappable {

var repoId: String?
var name: String?
var fullName: String?
var owner: Owner?
var description: String?
var size: Int?
var starsCount: Int?
var watchersCount: Int?
var language: String?
var forksCount: Int?
var createdDate: String?
var pushedDate: String?

required init?(_ map: Map) {

}

// Mappable
func mapping(map: Map) {
repoId <- map["id"]
name <- map["name"]
fullName <- map["full_name"]
owner <- map["owner"]
description <- map["description"]
size <- map["size"]
starsCount <- map["stargazers_count"]
language <- map["language"]
forksCount <- map["forks"]
createdDate <- map["created_at"]
pushedDate <- map["pushed_at"]
}
}

这里注意一下,Repo 的 JSON 里面没有 watchers,尽管搜索 Repo 的 API 的返回结果里面有个「watchers_count」的字段,但是这并不是 Github 中的 watchers,这只是一个过时的字段,取代它的新字段就是「stargazers_count」,也就是 stars,这两个字段的值是一样的。至于真正的 watchers,其实是在另一个 API 中,这个以后再说。

View Model

接下来是 ViewModel。SearchRepoViewModel 中的负责绑定的属性是搜索时的各种参数以及搜索结果列表,另外还负责「搜索」和「加载更多」这两个动作。网络库用的是著名的 Alamofire。
这里只贴加载下一页的代码片段,方法中的参数一个是加载完成后的处理(主要是刷新 tableView),一个是出错时的处理(显示 alert),这两个 closure 都在 view controller 中传入。

SearchRepoViewModel.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func loadMore(completion completion: ()->(), errorHandler: ((String) -> ())? = nil) {

currentPage += 1
let urlParams = [
"q": keyword,
"sort" : sortType.rawValue,
"page" : "\(currentPage)"
]
// Fetch Request
Alamofire.request(.GET, "https://api.github.com/search/repositories", parameters: urlParams)
.responseJSON { (response) in
switch response.result {
case .Success:
if let statusCode = response.response?.statusCode{
switch statusCode{
case 200..<299:
if let items = response.result.value!["items"], results = Mapper<Repo>().mapArray(items) {
self.repos.appendContentsOf(results)
completion()
}
default:
self.currentPage -= 1
if let message = response.result.value!["message"], errorHandler = errorHandler {
errorHandler(message as! String)
}
}

}
case .Failure(let error):
self.currentPage -= 1
if let errorHandler = errorHandler {
errorHandler(error.localizedDescription)
}
}
}
}

View

最后是 View。这部分的代码最多,既包括 views 也包括 view controllers。搜索这部分有三个文件:

  • SearchViewController.swift
  • SearchRepoCell.swift
  • SearchRepoCell.xib

搜索

关于 Cell 的两个文件就不详述了,没什么特别的。重点说说 SearchViewController。
首先是输入关键字进行搜索。UI 中用的是 UISearchBar,而不是自定义的 UITextField,所以在输入完点击键盘上的「搜索」按钮的动作需要实现 UISearchBarDelegatesearchBarSearchButtonClicked 这个方法:

SearchViewController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension SearchViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(searchBar: UISearchBar) {
print("Search for: ", searchBar.text!)
searchBar.endEditing(true)

viewModel.keyword = searchBar.text!

EZLoadingActivity.show("Searching...", disableUI: true)

viewModel.searchRepos(completion: {
self.tableView.reloadDataWithAutoSizingCells()
EZLoadingActivity.hide()
}, errorHandler: self.errorHandler)
}
}

点击「搜索」后,首先是隐藏键盘,然后将搜索框中的文本赋给 view model,利用 EZLoadingActivity 显示提示框,同时阻止其他 UI 操作,最后执行 view model 中的搜索。这里传给搜索方法的两个 closure,第一个就是成功搜索后刷新 table view 并隐藏提示框,第二个是搜索出错后的错误处理。

reloadDataWithAutoSizingCells 是我给 UITableView 增加的自定义的方法,为的是解决 table view 的一个 UI bug:就是 table view 第一次加载 Autolayout 的动态高度的 cell,会出现高度不正确的 bug。这个方法的实现是:

UITableView extension
1
2
3
4
5
6
7
8
extension UITableView {
func reloadDataWithAutoSizingCells() {
self.reloadData()
self.setNeedsDisplay()
self.layoutIfNeeded()
self.reloadData()
}
}

处理错误的 errorHandler 执行的代码就是隐藏提示框并弹出一个 alert,alert 的内容就是 API 中返回的错误信息。

error handler closure
1
2
3
4
5
6
7
8
errorHandler =  { [unowned self] msg in
EZLoadingActivity.hide()
self.isLoading = false
let alertController = UIAlertController(title: "", message: msg, preferredStyle: .Alert)
let action = UIAlertAction(title: "Okay", style: .Default, handler: nil)
alertController.addAction(action)
self.presentViewController(alertController, animated: true, completion: nil)
}

条件搜索

Github 的搜索可以按照「best match」,「stars」,「forks」和「updated」这四个条件排序搜索,这个 App 我默认都是降序。
在搜索框下面有一个 UISegmentedControl ,四个 segment 就对应着四个搜索条件。代码中先给这个 segmented control 添加一个 target,关联的 action 如下:

SegmentedControl Action
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func searchSortChanged(sender: UISegmentedControl) {
switch sender.selectedSegmentIndex {
case 0:
viewModel.sortType = .Best
case 1:
viewModel.sortType = .Stars
case 2:
viewModel.sortType = .Forks
case 3:
viewModel.sortType = .Updated
default:
viewModel.sortType = .Best
}

EZLoadingActivity.show("Loading", disableUI: true)
viewModel.searchRepos(completion: {
self.tableView.reloadDataWithAutoSizingCells()
let topIndexPath = NSIndexPath(forRow: 0, inSection: 0)
self.tableView .scrollToRowAtIndexPath(topIndexPath, atScrollPosition: UITableViewScrollPosition.Top, animated: true)
EZLoadingActivity.hide()
}, errorHandler: self.errorHandler)
}

加载更多

这个功能可以说是数据列表中的必备功能之一,也有多种实现方式。一开始我是通过 UITableViewDelegate 中的 tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) 这个方法来实现的,即在即将显示某个 cell (一般是 data source 中的倒数第X个)的时候加载下一页的数据。这是 SO 上面很多答案都推荐的方式,不过我发现这个方式有个问题,就是向下滑动过那个触发点 cell 后再向上滑的话,那么会再次执行一次加载动作(后来发现其实加一个是否在加载中的判断就可以了)。
最后我采用的方式是实现 UIScrollViewDelegate 中的 scrollViewDidEndDecelerating(scrollView: UIScrollView)。通过判断 scroll view 是否快滚到底来加载下一页数据,代码如下:

欢迎关注我的其它发布渠道