首页 > 解决方案 > RxSwift:如何在不创建类/结构属性的情况下为最后的网络响应创建缓存?

问题描述

我正在开发使用 IP Stack API 进行地理定位的 iOS 应用程序。我想通过首先询问外部(公共)IP 地址来优化 IP 堆栈 Api 的使用,然后在该 IP 没有更改的情况下重新使用该 IP 的 lat 响应。

所以我所追求的是我每次在https://www.ipify.org上询问外部 IP,然后用给定的 IP 地址询问https://ipstack.com 。如果我第二次询问但 IP 没有更改,则重新使用最后一个响应(或者实际上以 IP 作为键和响应作为值的缓存字典)。

我有一个解决方案,但我对代码中的这个缓存属性不满意。它是某种状态,而代码的其他一些部分可以改变它。我正在考虑scan()在 RxSwfit 中使用一些运算符,但我就是想不出任何新的想法。

class ViewController: UIViewController {
    @IBOutlet var geoButton: UIButton!

    let disposeBag = DisposeBag()
    let API_KEY = "my_private_API_KEY"

    let provider = PublicIPProvider()
    var cachedResponse: [String: Any] = [:] // <-- THIS

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func geoButtonTapped(_ sender: UIButton) {

        // my IP provider for ipify.org
        // .flatMap to ignore all nil values,
        // $0 - my structure to contains IP address as string
        let fetchedIP = provider.currentPublicIP()
            .timeout(3.0, scheduler: MainScheduler.instance)
            .flatMapLatest { Observable.from(optional: $0.ip) }
            .distinctUntilChanged()

        // excuse me my barbaric URL creation, it's just for demonstration
        let geoLocalization = fetchedIP
            .flatMapLatest { ip -> Observable<Any> in
                // check if cache contains response for given IP address
                guard let lastResponse = self.cachedResponse[ip] else {
                    return URLSession.shared.rx.json(request: URLRequest(url: URL(string: "http://api.ipstack.com/\(ip)?access_key=\(API_KEY)")! ))
                        .do(onNext: { result in
                            // store cache as a "side effect"
                            print("My result 1: \(result)")
                            self.cachedResponse[ip] = result
                        })
                }

                return Observable.just(lastResponse)
        }

        geoLocalization
            .subscribe(onNext: { result in
                print("My result 2: \(result)")
            })
            .disposed(by: disposeBag)
    }
}

是否有可能实现相同的功能但var cachedResponse: [String: Any] = [:]在我的班级中没有财产?

标签: iosswiftcachingrx-swiftreactive

解决方案


我的天啊!我花了很多时间来回答这个问题(见下文),然后意识到有一个更简单的解决方案。只需在 URLRequest 中传递正确的缓存参数,就可以完全取消内部缓存!我留下了原始答案,因为我还对您的代码进行了一般审查。

class ViewController: UIViewController {
    let disposeBag = DisposeBag()
    let API_KEY = "my_private_API_KEY"

    let provider = PublicIPProvider()

    @IBAction func geoButtonTapped(_ sender: UIButton) {
        // my IP provider for ipify.org
        let fetchedIP: Maybe<String> = provider.currentPublicIP() // `currentPublicIP()` returns a Single
            .timeout(3.0, scheduler: MainScheduler.instance)
            .map { $0.ip ?? "" }
            .filter { !$0.isEmpty }

        // excuse me my barbaric URL creation, it's just for demonstration
        let geoLocalization = fetchedIP
            .flatMap { (ip) -> Maybe<Any> in
                let url = URL(string: "http://api.ipstack.com/\(ip)?access_key=cce3a2a23ce22922afc229b154d08393")!
                return URLSession.shared.rx.json(request: URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad))
                    .asMaybe()
        }

        geoLocalization
            .observeOn(MainScheduler.instance)
            .subscribe(onSuccess: { result in
                print("My result 2: \(result)")
            })
            .disposed(by: disposeBag)
    }
}

原始答案

这里的简短回答是否定的。您可以做的最好的事情是将状态包装在一个类中以限制其访问。类似于这种通用方法的东西:

final class Cache<Key: Hashable, State> {
    init(qos: DispatchQoS, source: @escaping (Key) -> Single<State>) {
        scheduler = SerialDispatchQueueScheduler(qos: qos)
        getState = source
    }

    func data(for key: Key) -> Single<State> {
        lock.lock(); defer { lock.unlock() }
        guard let state = cache[key] else {
            let state = ReplaySubject<State>.create(bufferSize: 1)
            getState(key)
                .observeOn(scheduler)
                .subscribe(onSuccess: { state.onNext($0) })
                .disposed(by: bag)
            cache[key] = state
            return state.asSingle()
        }

        return state.asSingle()
    }

    private var cache: [Key: ReplaySubject<State>] = [:]
    private let scheduler: SerialDispatchQueueScheduler
    private let lock = NSRecursiveLock()
    private let getState: (Key) -> Single<State>
    private let bag = DisposeBag()
}

使用上述隔离您的状态并为其他需要缓存的情况创建一个很好的可重用组件。

我知道它看起来比您当前的代码更复杂,但可以优雅地处理在返回任何响应之前对同一个键有多个请求的情况。它通过将相同的响应对象推送给所有观察者来做到这一点。(schedulerandlock存在以保护data(for:)可以在任何线程上调用的。)

我还对您的代码提出了其他一些改进建议。

  • 而不是使用flatMapLatest来解开一个可选项,只需过滤掉可选项。但是在这种情况下,空字符串和零字符串有什么区别?最好使用 nil 合并运算符并过滤掉空。

  • 由于您在 IBAction 中有代码,我假设currentPublicIP()它只发出一个值并完成或出错。通过让它返回一个 Single 来明确这一点。如果它确实发出多个值,那么您正在为每个函数调用创建一个新链,并且它们将发出值。这不太可能是您想要的。

  • URLSession 的json(request:)函数在后台线程上发出。如果你打算用 UIKit 做任何事情,你需要在主线程上观察。

这是经过上述调整的结果代码:

class ViewController: UIViewController {
    private let disposeBag = DisposeBag()
    private let provider = PublicIPProvider()
    private let responses: Cache<String, Any> = Cache(qos: .userInitiated) { ip in
        return URLSession.shared.rx.json(request: URLRequest(url: URL(string: "http://api.ipstack.com/\(ip)?access_key=cce3a2a23ce22922afc229b154d08393")!))
            .asSingle()
    }

    @IBAction func geoButtonTapped(_ sender: UIButton) {
        // my IP provider for ipify.org
        let fetchedIP: Maybe<String> = provider.currentPublicIP() // `currentPublicIP()` returns a Single
            .timeout(3.0, scheduler: MainScheduler.instance)
            .map { $0.ip ?? "" }
            .filter { !$0.isEmpty }

        let geoLocalization: Maybe<Any> = fetchedIP
            .flatMap { [weak responses] ip in
                return responses?.data(for: ip).asMaybe() ?? Maybe.empty()
            }

        geoLocalization
            .observeOn(MainScheduler.instance) // this is necessary if your subscribe messes with UIKit
            .subscribe(onSuccess: { result in
                print("My result 2: \(result)")
            }, onError: { error in
                // don't forget to handle errors.
            })
            .disposed(by: disposeBag)
    }
}

推荐阅读