iOS开发技巧iOS 开发技巧swift

教你用Carthage+RXSwift+MVVM+Moya+Ro

2017-09-21  本文已影响2179人  黑暗中的孤影
惯例的开场美图

写了几个月Android后,我又回到了iOS了,经历过写Vue和Android后,我对这些平台的开发框架有了更深层次的认识,对三者的布局方式也有了很深的理解。相对于其他两个平台,iOS开发者更倾向于用代码来生成UI,再用Masory&Snapkit来写约束布局。在这种情况下,UI代码和逻辑代码都写在同一个Controller里,导致ViewController臃肿不堪,对于复杂的页面,代码行数上千并不罕见。这也是MVC框架让人诟病的一个因素之一。针对这种情况,一种解决方案就是用Storyboard&Xib布局,还有就是用MVVM框架将逻辑代码从ViewController里分离出来,而现在RXSwift的出现使得分离逻辑代码更加便捷了。下面我就教大家用Carthage+RXSwift+MVVM+Moya+Router写一个简单的小说阅读APP来感受RXSwift和其他框架如何实现iOS的MVVM框架。Demo 在此:Novel小说阅读

目前苹果更新了Swift4, 在XCode9下有些第三方库不支持Swift4,项目不能正常启动,但XCode8是没有问题的,这是Carthage的一个小坑。

首先给大家直接上图,让大家能更直观看到这个APP的操作逻辑和布局。


小说书签

首先给大家简单介绍上面的技术

Carthage

开发一个APP第三方库不是可缺少的,而目前最主流的iOS第三方库管理工具是CocoaPods,使用起来简单方便,而Carthage 就要轻量很多,它也要一个叫做 Cartfile描述文件,但 Carthage不会对我们的项目结构进行任何修改,更不多创建 workspace。它只是根据我们描述文件中配置的第三方库,将他们下载到本地,然后使用 xcodebuild 构建成 framework 文件。然后由我们自己将这些库集成到项目中。Carthage 使用的是一种非侵入性的哲学。
好了,下面就是怎么安装Carthage了,我使用的是Homebrew安装。假定你的Mac已经安装了Homebrew, 如果你没装Homebrew? 那么赶紧的,参考Mac下使用国内镜像安装Homebrew先安装好Homebrew
再用下面的命令

brew update
brew install carthage

然后就等Carthage安装好就能用了,然后cd到你的项目根目录建立Cartfile文件

touch Catfile

然后就是编辑这个文件了,你可以用编辑器修改也能用vim,在Cartfile文件里面加入以下内容:

github "Alamofire/Alamofire" ~> 4.0
github "tid-kijyun/Kanna" ~> 2.1.0
github "youngsoft/TangramKit" ~> 1.0.0
github "onevcat/Kingfisher" ~> 3.10.0
github "hackiftekhar/IQKeyboardManager"
github "devSC/WSProgressHUD"
github "ReactiveX/RxSwift"
github "Moya/Moya"
github "devxoul/URLNavigator"
github "RxSwiftCommunity/RxDataSources"

这些都是这个小说APP需要用到的库
然后再使用命令

carthage update --platform iOS

这个时侯再去喝茶吧,要等好一会Carthage才能将这些库下载过来再奖其编译成动态库。
注意小说APP用了MJRefresh,但是好像MJRefresh好像不技术Carthage,我不想再用Cocoapods,于是直接将这个库的文件放到项目内里面了。等待Carthage编译完成后,你就可以在项目目录->Carthage->Build->iOS里面看到这些framework库了,把这些库拖到项目targetGeneralLinked Frameworkds and Libraries里面

将这些库拖到项目里

注意,因为这里有些库需要依赖其他的库,所以也是要一并拖进来的。

再就是最后一步了设置Build Phases
选中你的工程target,到'Build Phases' tab下,点击 '+' 选择'New Run Script Phase',创建一个Script,添加以下内容:

/usr/local/bin/carthage copy-frameworks

然后添加相应的内容到下面的 'Input Files'

$(SRCROOT)/Carthage/Build/iOS/Alamofire.framework
$(SRCROOT)/Carthage/Build/iOS/TangramKit.framework
......


Build Phase

这样就大功告成了,但是Carthage让人不爽的是每次update都会Build都会重新编译一次,非常费时间。我特地问了别人何解,他说如果添加新的库,�将原先的库注释再使用命令Update就行,这样就不会将原先的库重新编译更多关于Carthage的内容,请看Carthage 使用 / 如何给自己的项目添加 Carthage 支持等文章。

RXSwift 和 MVVM

RXSwiftMVVM包含的内容可就多了,可以大书特书。网上的文章也是特别多,这里就不做说明了。我只想说了下为什么要用RXSwift来实现iOS开发MVVM。众所周知,MVVM最核心理念的就是数据双向绑定,用触发一个UI事件后,通常会更新其对应的数据,经过某些逻辑处理后,再更新数据来驱动UI更新,通常不用手动调用代码的方式直接操作UI。遗憾的是,相对于其他两个平台,iOSMVVM理念的支持最差的。对于AndroidWeb开发者来说,很难想象使用代码生成UI然后再绑定到ViewModel这样的操作,繁琐又低效。更别说iOS本身不提供绑定方法,也没有响应式的API和控件属性,现加上很多控件都是以Delegate的方式来设置样式和提供数据的。这样使得用MVVM很难适用在iOS开发上。而RXSwift的出现使得Swift语言具有响应式编程的能力,再加上RXCocoa封装了很多的Cocoa UI控件,使得它们的关键属性都有了响应式特性。这样就可以轻松将各种UI控件的属性或者事件绑定到ViewModel的属性和命令上,再通过响应式的API来操作数据,使得iOS开发更为高效和直观了。

当然,使用RXSwiftMVVM来开发APP缺点也不能忽视,主要有下面几点:

总之目前iOS最好的MVVM解决方案就是搭配RXSwift的一系统库来开发,基本满足了目前的开发需要。如果项目复杂不高,或者只是个人开发,可以使用这套方案,但如果需要开发大型商业项目,或者你的团队不熟悉MVVM,那么还是用传统的MVC方案更稳妥一些。

Moya

Moya是要和RXSwiftAlamofireObjectMapper搭配一起使用的,Moya可以十分简洁优雅的帮你完成网络请求。先上代码:

import Moya
enum APIManager {                    //先定义一个枚举,里面规定了这些请求的名称和参数
    case GetSearch(String,Int)          //搜索小说,如果参数多,建议传字典
    case GetSection(String)              //获取小说章节
    case GetNovel(String)                //获取小说内容
}
extension APIManager:TargetType{       // 扩展APIManager,让它实现Moya的TargetType协议
    var baseURL: URL{                         //获取BaseURL,一般来说,同一个项目BaseURL是相同的,但会根据使用CDN或者使用一些第三方服务而有不同
        switch self {
        case .GetSearch(_, _):
            return URL(string: "http://zhannei.baidu.com")!    //搜索小说使用此域名
        case .GetSection(_),.GetNovel(_):
            return URL(string: "http://www.37zw.net")!       //获取小说章节和内容使用此域名
        }
    }
    var path: String{                                      //获取BaseURL后面的路径
        switch self {
        case .GetSearch(_, _):                        //搜索小说使用此路径
            return "/cse/search"
        case .GetSection(let path),.GetNovel(let path):  //获取小说章节和内容用自定义路径
            return path
        }
    }
    var method: Moya.Method {              //这三个请求都用Get请求
        return .get
    }
    var parameterEncoding: ParameterEncoding {  //这三个请求都用默认编码
        return URLEncoding.default
    }
    var sampleData: Data {                          //这里是当API还没有开发好时自定义一些模拟数据
        return "".data(using: String.Encoding.utf8)!
    }
    var task: Moya.Task {                      //如果要设置请求参数,可以在这个属性里设置
        switch self {
         case .GetSearch(let key, let index):        //设置搜索的Key和index页码
            let params = ["q":key,
                          "p":index,
                          "isNeedCheckDomain":1,
                          "jump":"1",
                          "s":"2041213923836881982"] as [String : Any]
            return .requestParameters(parameters: params, encoding: URLEncoding.default)
            
        case .GetSection(_),.GetNovel(_): //这两个不需要其他参数
            return .requestPlain
        }
    }
    var validate: Bool {   //是否需要执行 Alamofire 验证
        return false
    }
    var headers: [String : String]?{        //设置HTTP 的Head内容,这里看后台的需求了
        return nil
    }
}

从上面的代码看出,我写 了三个请求接口。基本上相似的功能都可以写在同一个枚举里面。然后再设置请求方式,请求参数等数据就OK了

下一步就是发送请求了

let provider = RxMoyaProvider<APIManager>()   //定义一个provider

provider.request(.GetSection(url))                  //发送获取小说章节的请求
.filterSuccessfulStatusCodes()                          //�过滤失败的状态码
.mapSectionInfo()                                            //转换成Model
.subscribe({ [weak self] (str) in                        //订阅这个Observable
            switch(str){
            case let .success(result):        //处理成功 数据
                self!.currentSection.sectionContent = result.data as! String
                self?.arrSection.value += [self!.currentSection]
                self?.pageIndex += 1
                self!.currentSection = self!.arrSectionUrl![self!.pageIndex]
            case let  .error(err):     //处理失败情况
                Log(message: err)
               GrandCue.toast(err.localizedDescription)
            }
        }).addDisposableTo(self.bag)

没错,处理请求请求就是这么简单。你可以添加各种中间件来处理数据,非常方便。

ObjectMapper在这里并没有用到,因为这个APP抓取的是HTML网页,而不是JSON。所以我使用Kana来解析HTML

extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response{  //扩展PrimitiveSequence,然后里面的方法就可以用来处理Moyo返回的数据了
    func mapSectionInfo() -> Single<ResultInfo> {   //将HTML解析成ResultModel
        var result = ResultInfo()
        return flatMap { res -> Single<ResultInfo> in
            let code  = CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))
            let str = String(data: res.data, encoding: String.Encoding(rawValue: code))
            guard let doc =  HTML(html: str!, encoding: .utf8) else{
                result.code = 10
                result.message = "解析HTML错误"
                return Single.just(result)
            }
            let divs =  doc.xpath("//div[@id='list']").first!.css("dl > dd")
            if divs.count <= 0{
                result.data = [SectionInfo]()
                return Single.just(result)
            }
            var arrSections = [SectionInfo]()
            for link in divs{
                let section = SectionInfo()
                section.sectionName = link.css("a").first?.text ?? ""
                section.sectionUrl = link.css("a").first?["href"] ?? ""
                section.id = section.sectionUrl.hash
                arrSections.append(section)  
            }
            result.data = arrSections
            return Single.just(result)
        }
    }
}

相比于JSON处理HTML更麻烦一些,好在Model的字段并不多,所以不都需要写很多代码。

Router

最后再介绍Router,用了MVVM框架,再用iOS的传统导航方式就不合适了。因为导航这样的处理还是要放在ViewModel里面的,而ViewModel并不继承ViewController。而且navigationController会耦合各个页面的参数,增加修改成本。使用Router可以很好地解决这些问题。

小说APP我使用了URLNavigator,它是一个轻量级的iOS 路由库。它提供了一个优雅的方式来处理导航,使用起来也很简单。

extension NovelContentViewController:URLNavigable{
    convenience init?(navigation: Navigation) {
        guard let dict = navigation.navigationContext as? [String:Any] else { return nil }
        self.init()
       novelInfo = dict["novelInfo"] as? NovelInfo
       currentSection = dict["currentSection"] as? SectionInfo
       arrSectionUrl = dict["arrSectionUrl"] as? [SectionInfo]
    }
}

首先让需要导航的页面实现URLNavigable协议,实现init方法。

然后写一个初始化RouterMap的类

import UIKit
import URLNavigator
struct  NavigationMap{
    static func initialize(){
        Navigator.map(Routers.bookmark, BookmarkViewController.self) //注册这三个页面实现导航
        Navigator.map(Routers.sectionList, SectionListViewController.self)
        Navigator.map(Routers.novelContent, NovelContentViewController.self)
    }
}

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        ......
        NavigationMap.initialize()  //在APPDelegate注册
        ......
        return .true
    }

AppDelegateLaunch方法里注册后就可以使用了,比如点击了小说的某一章节导航到小说内容页面

tb.rx.itemSelected.subscribe(onNext: { (index) in
            guard  let section = wkself?.modelObserable.value[index.row] else{
                return
            }
            let dict = ["novelInfo":wkself!.novelInfo.value,"currentSection":section,"arrSectionUrl":wkself!.modelObserable.value] as [String : Any]   //设置导航的参数
            Navigator.push(Routers.novelContent, context: dict, from: nil, animated: true)    //发起导航
        }, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)

上面就是这个APP的主要的技术点了,下面将重点讲RXSwiftMVVM

ViewController和ViewModel怎么互动

以首页为例,和以前一样,ViewController只生成UI和布局。小说搜索页面比较简单,直接用代码生成即可。

接下来分析这个页面需要绑定哪些控件和属性
       var bag : DisposeBag = DisposeBag()
       let provider = RxMoyaProvider<APIManager>(requestClosure:MoyaProvider.myRequestMapping)
       var modelObserable = Variable<[NovelInfo]> ([])
       var refreshStateObserable = Variable<RefreshStatus>(.none)  //绑定到Table的刷新显示状态
       let requestNewDataCommond =  PublishSubject<Bool>()  //绑定到MJRefresh的上拉刷新和下拉加载更多事件
       var pageIndex = 0                 
       var tb : UITableView
       var key :Driver<String>                                 // key和搜索输入框绑定 
       var keyStr = Variable<String>.init("")            //搜索key变量,被key驱动
       var searchCommand :Driver<Void>              //搜索命令,和键盘事件,搜索按钮点击事件绑定
在ViewController里面加入ViewModel并绑定相关事件
 var vm : NovelSearchViewModel?    //声明对应的ViewModel
    
    override func viewDidLoad() {
        super.viewDidLoad()
         weak var wkself = self
        ......
        vm = NovelSearchViewModel(input: (tb,txtSearch.rx.text.orEmpty.asDriver(),btnSearch.rx.tap.asDriver()))  //实例化该ViewModel,传入必要参数
        
        txtSearch.rx.controlEvent([.editingDidEndOnExit]).subscribe(onNext: {
            wkself?.tb.mj_header.beginRefreshing()          //绑定搜索事件到上拉刷新
        }, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(vm!.bag)

        
        tb.mj_header = MJRefreshNormalHeader(refreshingBlock: {
            wkself?.vm?.requestNewDataCommond.onNext(true)      //绑定下拉刷新事件
        })
        
        tb.mj_footer = MJRefreshAutoNormalFooter(refreshingBlock: {
            wkself?.vm?.requestNewDataCommond.onNext(false)   /绑定下拉加载更多事件
        })
        ......
}
最后在ViewModel处理绑定事件和数据逻辑。
     func bind(){
          weak var wkself = self
          tb.register(NovelTbCell.self, forCellReuseIdentifier: cellID)        注册UITableViewCell
          tb.tableFooterView = UIView()
          modelObserable.asObservable().bind(to: tb.rx.items(cellIdentifier: cellID, cellType: NovelTbCell.self)){ row , model , cell in
                cell.novelIndo = model
          }.addDisposableTo(bag)        //将列表数据绑定到Table的单元格上面
        
        tb.rx.itemSelected.subscribe(onNext: { (index) in      //Table的单元格点击事件
            guard  let novel = wkself?.modelObserable.value[index.row] else{
                return
            }
            Navigator.push(Routers.sectionList, context: novel, from: nil, animated: true)    //跳转到小说章节列表页面
        }, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
        
        requestNewDataCommond.subscribe { [weak self](event) in
           Tool.hiddenKeyboard()
            if event.element!{
                self?.pageIndex = 0
                self?.provider.request(.GetSearch(self!.keyStr.value,self!.pageIndex)).filterSuccessfulStatusCodes().mapNovelInfo().subscribe({ (str) in   //使用Moya发起网络请求,网络请求的相关参数都在APIManager中设置好了
                    switch(str){
                    case let .success(result):
                            self?.modelObserable.value = result.data! as! [NovelInfo]  //更新数据,这个赋值操作可以触发UITableView更新数据
                            self?.refreshStateObserable.value = .endHeaderRefresh
                    case let  .error(err):
                        Log(message: err)
                        self?.refreshStateObserable.value = .endHeaderRefresh
                        GrandCue.toast(err.localizedDescription)
                    }
                }).addDisposableTo(self!.bag)
            }
            else{
               
            }
        }.addDisposableTo(bag)
        
        searchCommand.drive(onNext: {
            refreshStateObserable.value = .beginHeaderRefresh     //搜索命令触发MJRefresh下拉刷新
        }, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
       
        refreshStateObserable.asObservable().subscribe(onNext: { (status) in  //订阅刷新状态,刷新状态改变,将触发MJRefresh相关操作
            switch(status){
            case .beginHeaderRefresh:
                wkself?.tb.mj_header.beginRefreshing()
            case .endHeaderRefresh:
                wkself?.tb.mj_header.endRefreshing()
                wkself?.tb.mj_footer.resetNoMoreData()
            case .beginFooterRefresh:
                wkself?.tb.mj_footer.beginRefreshing()
            case .endFooterRefresh:
                wkself?.tb.mj_footer.endRefreshing()
            case .noMoreData:
                wkself?.tb.mj_footer.endRefreshingWithNoMoreData()
            default:
                break
            }
        }, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
      }

从上面的代码可以看出,主要是处理UITableView相关事件,为Table提供数据和单元格点击事件,并且修改MJRefresh状态。

上面就是ViewModeViewControlle交互三部曲:

其实MVVM的理念并不难理解,我感觉可能比MVC更简单,但是在iOS上MVVM确是一件不简单的事。如果在没有RXSwift和其他相关RX库的帮助的情况下,强行用MVVM理念来开发iOS,其复杂度可能要比传统的MVC开发高很多。��理论上,使用基于RX响应式编程的MVVM框架非常适合开发大型项目。在你使用RXSwift和其他RX库非常熟练的情况下,使用MVVM开发大型商业项目应该也不成问题了。

最后再次给出Demo :Novel小说阅读

如果读者想认真学习MVVM框架,一定要自己尝试写一个小项目来体验一下。如果你不知道怎么下手,建议Clone此项目然后再仿照写一次,相信你会对MVVM有一个完整认识了。如果此项目对你有帮助的话,不要忘记Star哦。

上一篇下一篇

猜你喜欢

热点阅读