美文网首页iOS DeveloperiOS_Vinch
用 RxSwift 为 Controller 瘦身(1),优雅的

用 RxSwift 为 Controller 瘦身(1),优雅的

作者: 大青虫Insect | 来源:发表于2018-07-18 20:05 被阅读208次

    概述

    View Controller 向来是 MVC (Model-View-View Controller) 中最让人头疼的一环,MVC 架构本身并不复杂,但开发者很容易将大量代码扔到用于协调 View 和 Model 的 Controller 中。你不能说这是一种错误,因为 View Controller 所承担的本来就是胶水代码和业务逻辑的部分。但是,持续这样做必定将导致 Model View Controller 变成 Massive View Controller,代码也就一天天烂下去,直到没人敢碰。

    写到后来,几经变换,最后你的 Controller 常常就变成了这样


    Controller 中含有大量代码的一个很大原因在于,大多数人都误用了 MVC,推荐可以看看喵神的这两篇文章,深入浅出。
    关于 MVC 的一个常见的误用
    单向数据流动的函数式 View Controller

    这篇文章我们先从网络层入手,在 iOS 开发中,网络请求与数据解析可以说是其中占比很高并且不可分割的一部分。

    身为一名 iOS 开发,也许你不知道 NSUrlConnection、也不知道 NSURLSession,但你一定知道 AFNetworking / Alamofire。对他们你肯定也做过一些自己的封装,或者直接采用业内比较知名的第三方封装。比如 Objective-C 中的 YTKNetworkSwift 中的 Moya 等等。

    那么问题来了,无论是自己封装也好还是直接采用第三方也好,在我们熟知的 MVC 模式中,你依旧需要在 Controller 中回调 Block / Delegate 对其做出处理,比如对返回数据的校验与解析,对指示器的控制,对刷新控件的控制,把 Model 赋值给 View 等等。而且在 iOS 中 Controller 本身就包含了一个 View,对其生命周期的管理和界面布局无疑又增加了 Controller 的负担。

    久而久之,当控制器中再加入一些其他的业务逻辑时,整个控制器里的代码就会变得非常臃肿,巨胖无比,随着业务的变更,代码的可读性会变得很差。其实 Controller 中大多数代码都可以被抽离出去,比如说我们的网络请求。

    让网络请求的代码更优雅

    本篇文章我们主要是针对 Moya 的再次封装扩展。其实 Moya 本身对网络层的封装已经很优秀了,自带了对于 RxSwift 这类函数响应式库的扩展,网络层非常清晰,并且提供了简单方便的网络单元测试。但我们依然可以把她变得更好。

    封装 Moya

    Moya 的使用我在这里就不贴了,没用过的小伙伴可以去官方文档学习一下。

    用过的小伙伴知道,我们使用 Moya 都要先创建一个 Enum 遵守 TargetType 协议实现对应的方法(比如指定请求的 URL 路径,参数等等)。

    public enum GitHub {
        case userProfile(String)
    }
    
    extension GitHub: TargetType {
    
        public var baseURL: URL { return URL(string: "https://api.github.com")! }
    
        public var path: String {
            switch self {
            case .userProfile(let name):
                return "/users/\(name.urlEscaped)"
            }
        }
    
        public var method: Moya.Method {
            return .get
        }
    
        public var task: Task {
            switch self {
            default:
                return .requestPlain
            }
        }
    }
    

    而实际的请求是使用 MoyaProvider<Target> 类,传入一个遵守 TargetType 协议的 Enum,创建 MoyaProvider 对象去请求的。

    provider = MoyaProvider<GitHub>()
    provider.request(.userProfile("InsectQY")) { result in
        // do something with the result
    }
    

    可是如果把项目中所有的网络请求都写在同一个 Enum 中的话,这个Enum里的代码会非常多,维护起来也并不方便。

    笔者在使用时通常都是根据模块创建多个 Enum,比如按首页模块,新闻模块这样划分。如果这么写的话,我们创建 MoyaProvider 对象时就不能再传入指定类型的 Enum 了。我们把创建对象的写法改成 MoyaProvider<MultiTarget>,所有传入的 Enum 得用 MultiTarget 包装一层。

    let provider = MoyaProvider<MultiTarget>
    provider.request(MultiTarget(GitHub.userProfile("InsectQY"))) { result in
        // do something with the result
    }
    

    看了上面的代码,好像已经开始变得不那么优雅了,我指定一个请求竟然要写这么多代码,一大堆括号看的眼睛都晕。能不能直接使用 Enum 的类型不需要借助 MoyaProvider 对象去请求呢,类似这样的效果。

    GitHub.userProfile("InsectQY").request
    

    以下封装我们基于 RxSwift 来实现,当然如果你不熟悉 RxSwift 也没关系,这里只是对封装思路的介绍,封装完成以后可以直接使用,等以后熟悉了 RxSwift 再回头看也行。以下文章的思路大多借鉴 RxNetwork 这个库的实现。

    首先我们为 TargetType 添加自己的 public extension 方便外界调用。

    public extension TargetType {
    
    }
    

    先实现一个可以直接使用 Enum 类型调用请求的方法。

    let provider = MoyaProvider<MultiTarget>
    
    public extension TargetType {
       func request() -> Single<Response> {
           return provider.rx.request(.target(self))
       }
    }
    

    这个方法返回一个 Single 类型的 ObservableSingleObservable 的另一个版本。它不像 Observable 可以发出多个元素,它要么只能发出一个元素,要么产生一个 error 事件,不共享状态变化,用来做请求的返回非常合适。

    写完我们就可以直接用 Enum 调用请求,怎么样是不是非常简单呢。代码的可读性也变高了很多。对请求的结果只需要调用 subscribe 去监听即可。

    GitHub.userProfile("InsectQY").request.subscribe...
    

    封装 JSON 解析

    先回顾一下我们以往的 JSON 解析,通常都是使用第三方解析库,直接把代码放到每次请求的回调中去处理。

    乍一看其实没毛病,那么这么做有什么弊端呢?其实这种写法侵入性很强,试想一下假如有一天你这个第三方解析库不维护了,或者种种原因你需要更换到其他的第三方,或者自己手写解析,那么你需要替换和修改的地方就非常多。

    你可能会说,那我可以在第三方解析的方法上封装一层,然后调用我自己的解析方法啊。是的,想法很好,但你有没有想过其实解析的写法可以变得非常优雅。

    Moya 自身就提供了基于 Codable 协议的原生解析方法。

    public func map<D>(_ type: D.Type, atKeyPath keyPath: String? = default, using decoder: JSONDecoder = default, failsOnEmptyData: Bool = default) throws -> D where D : Decodable
    

    支持对 JSON 指定路径的解析,实现的原理也非常简单,感兴趣的小伙伴可以去源码中学习一下。具体位置在 Response 这个类中搜索关键词即可。
    这个方法我们直接就能使用,转模型的代码可以写成这样

    GitHub.userProfile("InsectQY").request
    .map(UserModel.self)
    

    当然最好我们还是在原生方法上再封装一层,减少原生方法对项目的侵入性。

    需要注意的是,在我们平时使用 Codable 协议时,通常都要分清解析的是数组还是字典。如果是数组类型数据的话,必须得调用指定解析数组的方法,否则无法正确解析。

    Moya 是可以在外界直接传入数组类型的,具体实现也非常简单。用一个 Struct 的结构体去包装每次需要解析的对象,再把解析对象指定为包装好的结构体。

    private struct DecodableWrapper: Decodable {
        let value: T
    }
    

    这样就不用关心外界需要解析的具体类型,相当于每次解析的必然是一个包装好的字典类型,最后只要把结构体里的 value 返回就行。

    扯一个题外话,那这种实现思路在 Objective-C 中是否可行呢,可以思考如下两个问题。

    1. Objective-C 中我们使用 MJExtension / YYModel 这些库去解析 JSON 时,都要调用指定的解析方法(数组和字典的解析方法是不同的),能否用以上的思路把解析数组和解析字典的方法整合成一个方法呢?
    2. 如果要解析的模型中有个数组属性,数组里面又要装着其他模型。还要写指定数组内部类型的方法。
    // Tell MJExtension what type of model will be contained in statuses and ads.
    [StatusResult mj_setupObjectClassInArray:^NSDictionary *{
        return @{
                   @"statuses" : @"Status",
                   // @"statuses" : [Status class],
                   @"ads" : @"Ad"
                   // @"ads" : [Ad class]
               };
    }];
    
    + (NSDictionary *)modelContainerPropertyGenericClass {
        // value should be Class or Class name.
        return @{@"shadows" : [Shadow class],
                 @"borders" : Border.class,
                 @"attachments" : @"Attachment" };
    }
    

    这么写目的是为了在运行时拿到数组中元素的具体类型,再用 Runtime 去类中获取属性以及 KVC 赋值。如果用泛型指定数组里元素的具体类型的话,这些方法是否可以省略呢?

    然而很遗憾,原生的 Objective-C 是无法实现以上想法的。原因在于 Objective-C 的泛型只能算是"伪"泛型,仅仅是一个编译器特性,只能在编译时为 Xcode 提供具体类型,在运行时是没有的。

    封装网络缓存

    为了提升用户体验,在实际开发中,有一些内容可能会加载很慢,我们想先显示上次的内容,等加载成功后,再用最新的内容替换上次的内容。也有时候,由于网络处于断开状态,为了更加友好,我们想显示上次缓存中的内容。

    网络缓存我们基于 Cache 来实现。首先创建一个 CacheManager 统一处理所有的读取和存储操作。我们把读取模型数据和读取网络请求返回的 Response 数据分别创建不同的方法(这里只贴了模型的方法)。

    // MARK: - 读取模型缓存
        static func object<T: Codable>(ofType type: T.Type, forKey key: String) -> T? {
            do {
                
                let storage = try Storage(diskConfig: DiskConfig(name: "NetObjectCache"), memoryConfig: MemoryConfig(), transformer: TransformerFactory.forCodable(ofType: type))
                try storage.removeExpiredObjects()
                return (try storage.object(forKey: key))
            } catch {
                return nil
            }
        }
        
        // MARK: - 缓存模型
        static func setObject<T: Codable>(_ object: T, forKey: String) {
            
            do {
                
                let storage = try Storage(diskConfig: DiskConfig(name: "NetCache"), memoryConfig: MemoryConfig(), transformer: TransformerFactory.forCodable(ofType: T.self))
                try storage.setObject(object, forKey: forKey)
            } catch  {
                print("error\(error)")
            }
        }
    

    缓存的方法封装好以后,我们还需要知道缓存的 key,这里我们采用请求的 URL + 参数拼接成 key

    extension Task {
        
        public var parameters: String {
            switch self {
            case .requestParameters(let parameters, _):
                return "\(parameters)"
            case .requestCompositeData(_, let urlParameters):
                return "\(urlParameters)"
            case let .requestCompositeParameters(bodyParameters, _, urlParameters):
                return "\(bodyParameters)\(urlParameters)"
            default:
                return ""
            }
        }
    }
    
    public extension TargetType {
        var cachedKey: String {
            return "\(URL(target: self).absoluteString)?\(task.parameters)"
         }
    }
    

    万事俱备,现在为 TargetType 添加一个 cache 属性,返回一个 Observable 包装遵守 TargetType 协议的 Enum

    var cache: Observable<Self> {
        return Observable.just(self)
     }
    

    那么我们调用缓存的代码就变成了这样

    GitHub.userProfile("InsectQY").cache
    

    但是这个缓存还没有具体的实现,现在我们为缓存添加实现,只有遵守 TargetType 协议才能调用。

    每次调用方法都把请求结果缓存到本地,返回数据时先从本地获取,本地没有值时只返回网络数据。这里的 startWith 保证本地数据有值时,本地数据每次都优先在网络数据之前返回。

    extension ObservableType where E: TargetType {
        
        public func request() -> Observable<Response> {
            
            return flatMap { target -> Observable<Response> in
                
                let source = target.request().storeCachedResponse(for: target).asObservable()
                if let response = target.cachedResponse {
                    return source.startWith(response)
                }
                return source
            }
        }
    }
    

    现在我们的缓存已经初步完成了,在 onNext 回调中,第一次返回的是本地数据,第二次是网络数据。我们的请求就变成了这样

    GitHub.userProfile("InsectQY")
    .cache
    .request()
    .map(UserModel.self)
    .subscribe ...
    

    这样的好处是,每个方法之间都是独立的,我不想要缓存我只要去掉 cache 不想转模型只要去掉 map ,整段代码的可读性变得很强。

    由于 RxSwift 的存在,你也不需要在 Controller 销毁时去手动管理网络请求的取消。你想做一些网络的其他高级操作也变得非常容易,比如说链式的网络请求,group 式的网络请求,请求失败自动重试,同一个请求多次请求时短时间忽略相同的请求等等都非常简单。

    现在回头看看我们的需求,优先展示本地数据,网络数据返回时自动替换本地数据,网络请求失败时加载本地数据。

    但是这种写法应用场景相对比较单一,只能适用于本地数据和网络数据的处理是相同的情况。我们在 onNext 中无法区分本地数据和网络数据,假如想对本地数据做一些特殊处理的话是不行的。
    我们再完善一下代码,将本地数据的回调告诉外界。

    func onCache<T: Codable>(_ type: T.Type, atKeyPath keyPath: String? = "", _ onCache: ((T) -> ())?) -> OnCache<Self, T> {
            
        if let object = cachedObject(type) {onCache?(object)}
        return OnCache(self)
    }
    

    返回的 OnCache 对象是自定义的一个结构体

    public struct OnCache<Target: TargetType, T: Codable> {
        
        public let target: Target
        public let keyPath: String
        
        init(_ target: Target, _ keyPath: String) {
            
            self.target = target
            self.keyPath = keyPath
        }
        
        public func request() -> Single<T> {
            
            return target.request()
                    .mapObject(T.self, atKeyPath: keyPath)
                    .storeCachedObject(for: target)
        }
    }
    

    现在我们就可以在 onCache 的回调中拿到本地数据了,如果你想对本地数据做一些自己的操作和处理的话,选择第二种方案会更加合适。后续的 subscribe 监听到的是一个 Single ,如之前所说,只会返回成功或者失败,这里我们只把网络数据返回就好。这样就做到了网络数据和本地数据的区分。

    GitHub.userProfile("InsectQY")
    .onCache(UserModel.self, { (local) in
                    
    })
    .request()
    .subscribe ...
    

    总结

    好了看了以上这么多,我们只是对网络层做了一些封装,还没有做这种写法实际在项目中的应用,后续将教大家如何用 RxSwift 减少控制器的代码。

    具体的 demo 和用法可以查看我开源的这个项目 GamerSky

    相关文章

      网友评论

      • 桂宁813:感谢楼主的无私贡献, git 上 follow 楼主啦:smile:
        大青虫Insect:@qtds8810 谢谢:smile:
      • 我把今生当成了来世:moya 跟 swiftyJson 更配哦
        大青虫Insect:@我把今生当成了来世 SwiftyJSON 要自己去手写映射路径,不如 codable 来的方便
      • Caiflower:抄袭有点溜~, 不过人家已经更新了- -
        特别是 cache 这一块,Cache 更新到5.0版本试试
        大青虫Insect:@Caiflower 嗯,征询过原作者的意见了,文章也标注了地址
        Caiflower:@大青虫Insect https://github.com/Pircate/RxNetwork . :joy: 貌似你还留了Issues:stuck_out_tongue:
        大青虫Insect:@Caiflower 这个就是5.0啊,抄袭的链接贴一下?

      本文标题:用 RxSwift 为 Controller 瘦身(1),优雅的

      本文链接:https://www.haomeiwen.com/subject/wqxnuftx.html