用過無數(shù)的三方庫锌奴,卻仍舊寫不好代碼。以前總會(huì)有人問:你用過最好的三方庫是什么憾股?那個(gè)時(shí)候總是會(huì)猶豫半天鹿蜀,到底是哪一個(gè)呢?好像都還可以耶荔燎,直到后來遇到RxSwift耻姥,哇,簡直打開了新世界的大門∮凶桑現(xiàn)在我會(huì)毫不猶豫推薦它,雖然學(xué)習(xí)曲線有點(diǎn)陡峭蒸健,但是一旦你習(xí)慣上它座享,必深陷于其中無法自拔婉商。
初入RxSwift
在公司項(xiàng)目進(jìn)入版本迭代的時(shí)期,總覺得應(yīng)該學(xué)點(diǎn)什么渣叛,不然讓拍在沙灘上怎么辦丈秩?在學(xué)習(xí)swift3一段時(shí)間后,邂逅了響應(yīng)式編程方式淳衙,看了一下相關(guān)文章蘑秽,毫不猶豫跳入RxSwift的坑中,其中險(xiǎn)些放棄箫攀,還好堅(jiān)持下來了肠牲,現(xiàn)在也算入了個(gè)門。當(dāng)然只看看理論知識(shí)點(diǎn)靴跛,光紙上談兵是不行的缀雳,所以選擇仿寫知日報(bào)的方式來深化一下知識(shí)。
- 如果你不熟悉RxSwift相關(guān)的知識(shí)梢睛,可以先看看官方文檔
RxSwift - 如果你不知道為什么要使用RxSwift肥印,可以看看
why use rx? - 如果你想入坑,推薦官方的demo绝葡,也可以在簡書上搜索相關(guān)文章深碱,推薦幾篇文章:
使用 RxSwift 進(jìn)行響應(yīng)式編程,
Getting Started With RxSwift and RxCocoa藏畅,
RxSwift 入坑手冊 Part1 - 示例實(shí)戰(zhàn)
項(xiàng)目實(shí)戰(zhàn)
整個(gè)項(xiàng)目持續(xù)的大概兩周敷硅,遇到不少問題,畢竟不管對于Swift還是RxSwift來說墓赴,我大概都只是個(gè)新手竞膳。
網(wǎng)絡(luò)請求 Moya + RxSwift
- API: 項(xiàng)目的開始當(dāng)然是看看有沒有API呀,這里要感謝這位通過非正常手段獲取API的同學(xué)诫硕,為我們總結(jié)了完整的知乎日報(bào)-API-分析坦辟,我也無私地奉獻(xiàn)了star,略表感謝章办!
- Alamofire: Swift版的AFNetworking锉走。
- Moya: 是 Artsy 團(tuán)隊(duì)的 Ash Furrow 主導(dǎo)開發(fā)的一個(gè)網(wǎng)絡(luò)抽象層庫。它在 Alamofire 基礎(chǔ)上提供了一系列簡單的抽象接口藕届,讓客戶端代碼不用去直接調(diào)用 Alamofire挪蹭,也不用去關(guān)心 NSURLSession。同時(shí)提供了很多實(shí)用的功能休偶,包括對RxSwift的良好擴(kuò)展梁厉。
- HandyJSON: 是一個(gè)用于Swift語言中的JSON序列化/反序列化庫。與其他流行的Swift JSON庫相比,HandyJSON的特點(diǎn)是词顾,它支持純swift類八秃,使用也簡單。它反序列化時(shí)(把JSON轉(zhuǎn)換為Model)不要求Model從NSObject繼承(因?yàn)樗皇腔贙VC機(jī)制)肉盹,也不要求你為Model定義一個(gè)Mapping函數(shù)昔驱。只要你定義好Model類,聲明它服從HandyJSON協(xié)議上忍,HandyJSON就能自行以各個(gè)屬性的屬性名為Key骤肛,從JSON串中解析值。HandyJSON目前依賴于從Swift Runtime源碼中推斷的內(nèi)存規(guī)則窍蓝,任何變動(dòng)我們將隨時(shí)跟進(jìn)腋颠。
- RxSwift: 響應(yīng)式編程三方庫。這里主要處理網(wǎng)絡(luò)請求時(shí)的各種回調(diào)和異步線程它抱。
最終實(shí)現(xiàn)效果:
let provider = RxMoyaProvider<ApiManager>()
provider //moya網(wǎng)絡(luò)請求的manager
.request(.getNewsList) //各種請求以枚舉的形式調(diào)用
.mapModel(listModel.self) //JOSN->Model
.subscribe(onNext: { (model) in
print(model) //請求數(shù)據(jù)回調(diào)秕豫,處理數(shù)據(jù)
})
.addDisposableTo(dispose) //資源回收
API枚舉:
enum ApiManager {
case getLaunchImg
case getNewsList
case getMoreNews(String)
case getThemeList
case getThemeDesc(Int)
case getNewsDesc(Int)
}
由于Moya沒有支持HandyJSON擴(kuò)展,這里我自己實(shí)現(xiàn)了此擴(kuò)展:
extension ObservableType where E == Response {
public func mapModel<T: HandyJSON>(_ type: T.Type) -> Observable<T> {
return flatMap { response -> Observable<T> in
return Observable.just(response.mapModel(T.self))
}
}
}
extension Response {
func mapModel<T: HandyJSON>(_ type: T.Type) -> T {
let jsonString = String.init(data: data, encoding: .utf8)
return JSONDeserializer<T>.deserializeFrom(json: jsonString)!
}
}
只要Model遵循HandyJSON協(xié)議观蓄,就能很優(yōu)雅的快速實(shí)現(xiàn)JSON->Model混移,包括嵌套解析:
struct listModel: HandyJSON {
var date: String?
var stories: [storyModel]?
var top_stories: [storyModel]?
}
struct storyModel: HandyJSON {
var ga_prefix: String?
var id: Int?
var images: [String]? //list_stories
var title: String?
var type: Int?
var image: String? //top_stories
var multipic = false
}
可以說,這是迄今為止我最滿意的網(wǎng)絡(luò)請求封裝侮穿,以后都可以愉快處理請求啦??
數(shù)據(jù)呈現(xiàn)
數(shù)據(jù)請求處理好了歌径,就該綁定視圖顯示出來了,這里就是RxSwift的拿手好戲了亲茅。下面我們先看最簡單的展現(xiàn):
let provider = RxMoyaProvider<ApiManager>()
let dispose = DisposeBag()
let themeArr = Variable([ThemeModel]())
//請求數(shù)據(jù)
provider
.request(.getThemeList)
.mapModel(ThemeResponseModel.self)
.subscribe(onNext: { (model) in
self.themeArr.value = model.others!
})
.addDisposableTo(dispose)
//綁定視圖
themeArr
.asObservable()
.bindTo(tableView.rx.items(cellIdentifier: "ThemeTableViewCell", cellType: ThemeTableViewCell.self)) {
row, model, cell in
cell.name.text = model.name
cell.homeIcon.isHidden = row == 0 ? false : true
cell.nameLeft.constant = row == 0 ? 50 : 15
}
.addDisposableTo(dispose)
//響應(yīng)視圖
tableView.rx
.modelSelected(ThemeModel.self)
.subscribe(onNext: { (model) in
self.showView = false
self.showThemeVC(model)
})
.addDisposableTo(dispose)
這樣簡單的幾行代碼就完成網(wǎng)絡(luò)請求數(shù)據(jù)展現(xiàn)以及用戶響應(yīng)一系列流程回铛,什么代理,擴(kuò)展都不用寫了克锣,減少了一半以上的代碼茵肃,是不是看著就覺得爽炸了!我們再看看復(fù)雜一點(diǎn)的袭祟,分組tableview:
let dataSource = RxTableViewSectionedReloadDataSource<SectionModel<String, storyModel>>()
let dispose = DisposeBag()
dataSource.configureCell = { (dataSource, tv, indexPath, model) in
let cell = tv.dequeueReusableCell(withIdentifier: "ListTableViewCell") as! ListTableViewCell
cell.title.text = model.title
cell.img.kf.setImage(with: URL.init(string: (model.images?.first)!))
cell.morepicImg.isHidden = !model.multipic
return cell
}
dataArr
.asObservable()
.bindTo(tableView.rx.items(dataSource: dataSource))
.addDisposableTo(dispose)
tableView.rx
.modelSelected(storyModel.self)
.subscribe(onNext: { (model) in
self.tableView.deselectRow(at: self.tableView.indexPathForSelectedRow!, animated: true)
let detailVc = DetailViewController()
detailVc.id = model.id!
self.navigationController?.pushViewController(detailVc, animated: true)
})
.addDisposableTo(dispose)
其實(shí)也很簡單验残,就是需要綁定SectionModel,當(dāng)然你也可以自定義SectionModel來分組展示巾乳,上面的代碼都在項(xiàng)目篩選出來的您没,具體實(shí)現(xiàn)可以看文末項(xiàng)目鏈接。
項(xiàng)目難點(diǎn)
1. 菜單欄與主頁面的切換
由于導(dǎo)航欄一開始用的原生的(其實(shí)應(yīng)該自定義胆绊,因?yàn)楹竺嫔婕暗胶芏鄬?dǎo)航欄問題)氨鹏,所以左右平移的時(shí)候要把導(dǎo)航欄一起移動(dòng),所以遇到了一點(diǎn)問題压状,后來查找相關(guān)資料后解決了此問題:
func showMenu() {
let view = UIApplication.shared.keyWindow?.subviews.first
let menuView = UIApplication.shared.keyWindow?.subviews.last
UIApplication.shared.keyWindow?.bringSubview(toFront: (UIApplication.shared.keyWindow?.subviews[1])!)
UIView.animate(withDuration: 0.5, animations: {
view?.transform = CGAffineTransform.init(translationX: 225, y: 0)
menuView?.transform = (view?.transform)!
})
}
func dismissMenu() {
let view = UIApplication.shared.keyWindow?.subviews.first
let menuView = UIApplication.shared.keyWindow?.subviews.last
UIApplication.shared.keyWindow?.bringSubview(toFront: (UIApplication.shared.keyWindow?.subviews[1])!)
UIView.animate(withDuration: 0.5, animations: {
view?.transform = CGAffineTransform.init(translationX: 0, y: 0)
menuView?.transform = (view?.transform)!
})
}
菜單欄的顯示和隱藏需要配合手勢仆抵,研究官方知乎日報(bào)App后,發(fā)現(xiàn)存在輕掃和拖拽滑動(dòng)兩個(gè)手勢,相對應(yīng)UIPanGestureRecognizer和UISwipeGestureRecognizer肢础,當(dāng)把這兩個(gè)視圖分別加在視圖上的時(shí)候还栓,只會(huì)響應(yīng)一個(gè)手勢碌廓,后來設(shè)置UIGestureRecognizerDelegate后避免了這個(gè)問題:
extension HomeViewController: UIGestureRecognizerDelegate {
//是否允許手勢識(shí)別器同時(shí)識(shí)別兩個(gè)手勢
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
本以為就此解決了問題传轰,但是實(shí)際操作起來,手機(jī)很難區(qū)分這個(gè)兩個(gè)手勢谷婆,經(jīng)常會(huì)搞錯(cuò)慨蛙,本來想拖拽滑動(dòng)結(jié)果系統(tǒng)識(shí)別為了輕掃手勢,體驗(yàn)效果很差纪挎,那怎么辦呢期贫?后來終于找到一種可行方案:只在視圖上添加UIPanGestureRecognizer,以手指操作時(shí)間來區(qū)分是輕掃還是拖拽滑動(dòng)
func panGesture(pan: UIPanGestureRecognizer) {
let xoff = pan.translation(in: view).x
if pan.state == .began {
beganDate = Date()
}
if pan.state == .ended {
endDate = Date()
//區(qū)分是輕掃還是滑動(dòng)
if endDate! < beganDate! + 150000000.nanoseconds {
if xoff > 0 {
showView = true
} else {
showView = false
}
return
}
}
//滑動(dòng)范圍以及滑動(dòng)結(jié)束后需要show還是dismiss
if (0 < xoff && xoff <= 225 && !showView) || (0 > xoff && xoff >= -225 && showView) {
if pan.translation(in: view).x > 0 {
moveMenu(pan.translation(in: view).x)
} else {
moveMenu(225 + pan.translation(in: view).x)
}
if pan.state == .ended {
if showView {
if pan.translation(in: view).x < -175 {
showView = false
} else {
showView = true
}
} else {
if pan.translation(in: view).x > 50 {
showView = true
} else {
showView = false
}
}
}
}
}
菜單欄與主頁面的切換中還有一個(gè)不好處理的點(diǎn)异袄,當(dāng)選中菜單欄某個(gè)主題后通砍,要推出一個(gè)主題日報(bào)列表,與首頁不同屬于一個(gè)UINavigationController烤蜕,那怎么從一個(gè)UINavigationController到另一個(gè)UINavigationController呢封孙?試了好幾種方式來切換,始終達(dá)不到官方效果讽营,忙碌了一天虎忌,最后靈光一現(xiàn)(也可能是我太蠢??)平常不是都用UITabBarController來切換UINavigationController?橱鹏!真的好簡單膜蠢,隱藏掉tabbar就好,幾句代碼就完美解決了這個(gè)場景切換問題:
func showThemeVC(_ model: ThemeModel) {
if model.id == nil {
bindtoNav?.selectedIndex = 0
} else {
bindtoNav?.selectedIndex = 1
}
}
如果你有更好的切換方法請聯(lián)系我莉兰,愿意請你喝咖啡??
2. 文章的快速切換
文章詳情是用UIWebView加載html數(shù)據(jù)來展現(xiàn)的挑围,這里我自定義class DetailWebView: UIWebView,以便于兩個(gè)文章詳情的切換糖荒,用于顯示文章詳情的DetailViewController包含兩個(gè)DetailWebView杉辙,一個(gè)webview用于展示當(dāng)前頁面,另一個(gè)previousWeb放在屏幕外準(zhǔn)備隨時(shí)切換文章寂嘉,當(dāng)發(fā)生切換文章時(shí)奏瞬,動(dòng)畫呈現(xiàn)previousWeb,并在后續(xù)移除在屏幕外webview泉孩,把previousWeb作為新的webview硼端,同時(shí)生成新的previousWeb
//切換文章詳情
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y <= -60 {
if previousId > 0 {
previousWeb.frame = CGRect.init(x: 0, y: -screenH, width: screenW, height: screenH)
UIView.animate(withDuration: 0.3, animations: {
self.webview.transform = CGAffineTransform.init(translationX: 0, y: screenH)
self.previousWeb.transform = CGAffineTransform.init(translationX: 0, y: screenH)
}, completion: { (state) in
if state { self.changeWebview(self.previousId) }
})
}
}
if scrollView.contentOffset.y - 50 + screenH >= scrollView.contentSize.height {
if nextId > 0 {
previousWeb.frame = CGRect.init(x: 0, y: screenH, width: screenW, height: screenH)
UIView.animate(withDuration: 0.3, animations: {
self.previousWeb.transform = CGAffineTransform.init(translationX: 0, y: -screenH)
self.webview.transform = CGAffineTransform.init(translationX: 0, y: -screenH)
}, completion: { (state) in
if state { self.changeWebview(self.nextId) }
})
}
}
}
//切換之后后續(xù)處理
func changeWebview(_ showID: Int) {
webview.removeFromSuperview()
previousWeb.scrollView.delegate = self
previousWeb.delegate = self
webview = previousWeb
id = showID
setUI()
previousWeb = DetailWebView.init(frame: CGRect.init(x: 0, y: -screenH, width: screenW, height: screenH))
view.addSubview(previousWeb)
scrollViewDidScroll(webview.scrollView)
}
3.首頁刷新
Swift版的刷新控件三方還沒找比較好的,一度打算自己封裝一個(gè)寓搬,但是一直拖著珍昨,??以后應(yīng)該會(huì)寫。
知乎日報(bào)的刷新控件與一般放在tableview上不同,它應(yīng)該是放在導(dǎo)航欄上面镣典,配合tableview來實(shí)現(xiàn)刷新兔毙,這也是前面為什么說導(dǎo)航欄要自定義的原因之一,因?yàn)橐呀?jīng)用了原生的導(dǎo)航欄兄春,只好巧妙(偷懶)加在了view上澎剥,其實(shí)這個(gè)刷新就是一個(gè)畫圓圈的過程,下面看看自定義的RefreshView:
class RefreshView: UIView {
let circleLayer = CAShapeLayer()
let indicatorView = UIActivityIndicatorView().then {
$0.frame = CGRect(x: 0, y: 0, width: 16, height: 16)
}
fileprivate var refreshing = false
fileprivate var endRef = false
override init(frame: CGRect) {
super.init(frame: frame)
creatCircleLayer()
}
override func layoutSubviews() {
super.layoutSubviews()
circleLayer.position = CGPoint(x: frame.width/2, y: frame.height/2)
indicatorView.center = CGPoint(x: frame.width/2, y: frame.height/2)
}
func creatCircleLayer() {
circleLayer.path = UIBezierPath(arcCenter: CGPoint(x: 8, y: 8),
radius: 8,
startAngle: CGFloat(M_PI_2),
endAngle: CGFloat(M_PI_2 + 2*M_PI),
clockwise: true).cgPath
circleLayer.strokeColor = UIColor.white.cgColor
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.strokeStart = 0.0
circleLayer.strokeEnd = 0.0
circleLayer.lineWidth = 1.0
circleLayer.lineCap = kCALineCapRound
circleLayer.bounds = CGRect(x: 0, y: 0, width: 16, height: 16)
circleLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
layer.addSublayer(circleLayer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension RefreshView {
//向下拖拽視圖準(zhǔn)備刷新的過程會(huì)響應(yīng)
func pullToRefresh(progress: CGFloat) {
circleLayer.strokeEnd = progress
}
//開始刷新
func beginRefresh(begin: @escaping () -> Void) {
if refreshing {
//防止刷新未結(jié)束又開始請求刷新
return
}
refreshing = true
circleLayer.removeFromSuperlayer()
addSubview(indicatorView)
indicatorView.startAnimating()
begin()
}
//結(jié)束刷新
func endRefresh() {
refreshing = false
indicatorView.stopAnimating()
indicatorView.removeFromSuperview()
}
//重制刷新控件
func resetLayer() {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
self.creatCircleLayer()
}
}
}
注意事項(xiàng)
總結(jié)
小生才疏學(xué)淺,未有編程天賦芜茵,難免有許多謬誤紕漏之處叙量,各位看官當(dāng)看且看,若有任何問題都可以提出九串,愿接受各種批評建議绞佩。要是覺得這篇文章稍有用處,可以給個(gè)star猪钮,十分感激品山。