用 RxSwift 为 Controller 瘦身(1),优雅的使用网络请求(Moya) + 数据缓存(Cache)。

概述

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
或者原作者的 RxNetwork

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    X先生_未知数的X阅读 15,937评论 3 118
  • 我像一个小孩行走在黑暗的森林里,夜晚,我总是能看见星星,但每每想到在这诺大的森林里,只有我一个人看星星,就十分无助...
    杜绫阅读 611评论 6 5
  • 马俪娜阅读 226评论 0 0
  • 我们的贵人和伯乐是谁? 他们长什么样子,在什么地方? 以便我们能够更好地辨认出他们。 01 我们真正的自己是怎样的...
    驭爷阅读 1,155评论 1 4
  • 供养在镜框里的天使 在离我很远很远的地方 挤不进她的身边 无法穿越过这透明的高墙 心儿是被她偷袭了的宫殿 她一侵占...
    吴生善阅读 215评论 0 1