墜入鏈?zhǔn)骄幊痰幕脴防?-用RxSwift仿寫知乎日?qǐng)?bào)

用過無數(shù)的三方庫,卻仍舊寫不好代碼。以前總會(huì)有人問:你用過最好的三方庫是什么?那個(gè)時(shí)候總是會(huì)猶豫半天,到底是哪一個(gè)呢?好像都還可以耶,直到后來遇到RxSwift,哇,簡(jiǎn)直打開了新世界的大門。現(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),光紙上談兵是不行的,所以選擇仿寫知日?qǐng)?bào)的方式來深化一下知識(shí)。

項(xiàng)目實(shí)戰(zhàn)

整個(gè)項(xiàng)目持續(xù)的大概兩周,遇到不少問題,畢竟不管對(duì)于Swift還是RxSwift來說,我大概都只是個(gè)新手。

網(wǎng)絡(luò)請(qǐng)求 Moya + RxSwift
  • API: 項(xiàng)目的開始當(dāng)然是看看有沒有API呀,這里要感謝這位通過非正常手段獲取API的同學(xué),為我們總結(jié)了完整的知乎日?qǐng)?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ǔ)上提供了一系列簡(jiǎn)單的抽象接口,讓客戶端代碼不用去直接調(diào)用 Alamofire,也不用去關(guān)心 NSURLSession。同時(shí)提供了很多實(shí)用的功能,包括對(duì)RxSwift的良好擴(kuò)展。
  • HandyJSON: 是一個(gè)用于Swift語言中的JSON序列化/反序列化庫。與其他流行的Swift JSON庫相比,HandyJSON的特點(diǎn)是,它支持純swift類,使用也簡(jiǎn)單。它反序列化時(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ò)請(qǐng)求時(shí)的各種回調(diào)和異步線程。

最終實(shí)現(xiàn)效果:

let provider = RxMoyaProvider<ApiManager>()
provider                                      //moya網(wǎng)絡(luò)請(qǐng)求的manager
    .request(.getNewsList)                    //各種請(qǐng)求以枚舉的形式調(diào)用
    .mapModel(listModel.self)                 //JOSN->Model
    .subscribe(onNext: { (model) in
        print(model)                          //請(qǐng)求數(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ò)請(qǐng)求封裝,以后都可以愉快處理請(qǐng)求啦??

數(shù)據(jù)呈現(xiàn)

數(shù)據(jù)請(qǐng)求處理好了,就該綁定視圖顯示出來了,這里就是RxSwift的拿手好戲了。下面我們先看最簡(jiǎn)單的展現(xiàn):

    let provider = RxMoyaProvider<ApiManager>()
    let dispose = DisposeBag()
    let themeArr = Variable([ThemeModel]())
        
        //請(qǐng)求數(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)

這樣簡(jiǎn)單的幾行代碼就完成網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù)展現(xiàn)以及用戶響應(yīng)一系列流程,什么代理,擴(kuò)展都不用寫了,減少了一半以上的代碼,是不是看著就覺得爽炸了!我們?cè)倏纯磸?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í)也很簡(jiǎn)單,就是需要綁定SectionModel,當(dāng)然你也可以自定義SectionModel來分組展示,上面的代碼都在項(xiàng)目篩選出來的,具體實(shí)現(xiàn)可以看文末項(xiàng)目鏈接。

項(xiàng)目難點(diǎn)
1. 菜單欄與主頁面的切換
menuShow.gif

由于導(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)!
        })
    }

菜單欄的顯示和隱藏需要配合手勢(shì),研究官方知乎日?qǐng)?bào)App后,發(fā)現(xiàn)存在輕掃和拖拽滑動(dòng)兩個(gè)手勢(shì),相對(duì)應(yīng)UIPanGestureRecognizer和UISwipeGestureRecognizer,當(dāng)把這兩個(gè)視圖分別加在視圖上的時(shí)候,只會(huì)響應(yīng)一個(gè)手勢(shì),后來設(shè)置UIGestureRecognizerDelegate后避免了這個(gè)問題:

extension HomeViewController: UIGestureRecognizerDelegate {
    //是否允許手勢(shì)識(shí)別器同時(shí)識(shí)別兩個(gè)手勢(shì)
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

本以為就此解決了問題,但是實(shí)際操作起來,手機(jī)很難區(qū)分這個(gè)兩個(gè)手勢(shì),經(jīng)常會(huì)搞錯(cuò),本來想拖拽滑動(dòng)結(jié)果系統(tǒng)識(shí)別為了輕掃手勢(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è)主題日?qǐng)?bào)列表,與首頁不同屬于一個(gè)UINavigationController,那怎么從一個(gè)UINavigationController到另一個(gè)UINavigationController呢?試了好幾種方式來切換,始終達(dá)不到官方效果,忙碌了一天,最后靈光一現(xiàn)(也可能是我太蠢??)平常不是都用UITabBarController來切換UINavigationController?!真的好簡(jiǎn)單,隱藏掉tabbar就好,幾句代碼就完美解決了這個(gè)場(chǎng)景切換問題:

    func showThemeVC(_ model: ThemeModel) {
        if model.id == nil {
            bindtoNav?.selectedIndex = 0
        } else {
            bindtoNav?.selectedIndex = 1
        }
    }

如果你有更好的切換方法請(qǐng)聯(lián)系我,愿意請(qǐng)你喝咖啡??

2. 文章的快速切換
newsChange.gif

文章詳情是用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ì)寫。
知乎日?qǐng)?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é)束又開始請(qǐng)求刷新
            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)

  • 項(xiàng)目中關(guān)于時(shí)間的處理用的是 SwiftDate
  • .then 語法用的是 Then,小而妙,很喜歡

總結(jié)

小生才疏學(xué)淺,未有編程天賦,難免有許多謬誤紕漏之處,各位看官當(dāng)看且看,若有任何問題都可以提出,愿接受各種批評(píng)建議。要是覺得這篇文章稍有用處,可以給個(gè)star,十分感激。

項(xiàng)目源碼:ZhiHu-RxSwift

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,491評(píng)論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評(píng)論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評(píng)論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,708評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評(píng)論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,409評(píng)論 0 288
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,939評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,774評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,976評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評(píng)論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,209評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評(píng)論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,650評(píng)論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,958評(píng)論 2 373

推薦閱讀更多精彩內(nèi)容