RxSwift: Как создать кэш для последнего сетевого ответа без создания свойства class / struct? - PullRequest
0 голосов
/ 20 декабря 2018

Я работаю над приложением iOS, которое использует API стека IP для геолокации.Я хотел бы оптимизировать использование IP Stack Api, сначала запросив внешний (публичный) IP-адрес, а затем повторно использовать ответ lat для этого IP-адреса, если он не изменился.

Итак, что мне нужноявляется то, что я спрашиваю каждый раз https://www.ipify.org о внешнем IP, затем спрашиваю https://ipstack.com с данным IP-адресом.Если я спрашиваю второй раз, но 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] = [:] в моем классе

Ответы [ 2 ]

0 голосов
/ 20 декабря 2018

Боюсь, что если у вас нет способа кешировать ваши сетевые ответы (в идеале, с помощью встроенного механизма кэширования URLRequest), всегда будут побочные эффекты.

Вот совет, чтобы попытаться сохранить их в секрете:

Используйте Rx для нажатия кнопки, а также избавьтесь от @IBAction.Нехорошо иметь весь этот код в @IBAction в любом случае (если только вы не сделали это для демонстрационных целей).

Таким образом, вы можете использовать переменную локальной области видимости внутри функции установки, которая будет толькозахвачен вашим flatMapLatest закрытием.Это делает хороший и чистый код и помогает вам убедиться, что ваш словарь cachedResponse не подделан другими функциями в вашем классе.

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

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

    let provider = PublicIPProvider()

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

    func prepareGeoButton() {
        // ----> Use RxCocoa UIButton.rx.tap instead of @IBAction
        let fetchedIP = geoButton.rx.tap
            .flatMap { _ in self.provider.currentPublicIP() }
            .timeout(3.0, scheduler: MainScheduler.instance)
            .flatMapLatest { Observable.from(optional: $0.ip) }
            .distinctUntilChanged()

        // ----> Use local variable. 
        // Still has side-effects, but is much cleaner and safer.
        var cachedResponse: [String: Any] = [:]

        let geoLocalization = fetchedIP
            .flatMapLatest { ip -> Observable<Any> in
                // check if cache contains response for given IP address
                guard let lastResponse = cachedResponse[ip] else {
                    return URLSession.shared.rx.json(request: URLRequest(url: URL(string: "http://api.ipstack.com/\(ip)?access_key=cce3a2a23ce22922afc229b154d08393")! ))
                        .do(onNext: { result in
                            print("My result 1: \(result)")
                            cachedResponse[ip] = result
                        })
                }

                return Observable.just(lastResponse)
        }

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

Я не хотел слишком сильно менять код по сравнению с тем, чтоу вас есть и добавьте больше деталей реализации в микс, но если вы решите пойти по этому пути, пожалуйста:

a) Используйте Driver вместо наблюдаемого для нажатия кнопки. Подробнее о драйверах здесь .

b) Используйте [weak self] внутри ваших затворов.Не сохраняйте self, так как это может привести к тому, что ViewController останется в памяти несколько раз, когда вы отойдете от текущего экрана в середине сетевого запроса или от какого-либо другого длительного действия.

0 голосов
/ 20 декабря 2018

OMG!Я потратил кучу времени на ответ на этот вопрос (см. Ниже), а затем понял, что есть гораздо более простое решение.Просто передайте правильный параметр кэширования в свой 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()
}

Использование вышеизложенного изолирует ваше состояние и создает хороший повторно используемый компонент для других ситуаций, где необходим кеш.

Я знаю, что это выглядит сложнее, чем ваштекущий код, но изящно обрабатывает ситуацию, когда есть несколько запросов на один и тот же ключ, прежде чем любой ответ будет возвращен.Он делает это, передавая один и тот же объект ответа всем наблюдателям.(scheduler и lock существуют для защиты data(for:), который может быть вызван в любом потоке.)

У меня есть и другие предлагаемые улучшения для вашего кода.

  • Вместо того, чтобы использовать flatMapLatest, чтобы развернуть опцию, просто отфильтруйте опцию.Но в этом случае, в чем разница между пустой строкой и нулевой строкой?Лучше было бы использовать оператор слияния ноль и отфильтровать пустые.

  • Поскольку у вас есть код в IBAction, я предполагаю, что currentPublicIP() испускает только одно значение и завершается или содержит ошибки.Проясните это, заставив его вернуть сингл.Если действительно испускает несколько значений, то вы создаете новую цепочку с каждым вызовом функции, и все из них будут излучать значения.Вряд ли это то, что вам нужно.

  • Функция 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)
    }
}
...