Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Cache invalidation in ReactiveCocoa

I'm still wrapping my head around RAC and FRP in general - currently struggling to figure out how to implement a pattern I've commonly had to use elsewhere.

Let's say I'm making a flashcard app, and the main screen is a list of my decks of cards. This app uses the network server's state as source of truth. I don't want to re-fetch this list of decks from the server every time the screen is displayed - so great, I can use a deferred network request in a multicast signal with a replay subject to effectively memoize that list.

I have two ways where this list should refresh by re-fetching from the server, which is where it gets complicated for me. I want to be able to invalidate this "cached" list when any number of things happen in the app (e.g. user navigates to some other screen and does something that would make the list of decks on the home screen out of date, or the app has just been reawoken so we can guess it may be out of date to be safe), so that the next time the user returns to this home screen, it will show nothing at first (rather than show the old list, since it knows it's out of date due to the user's action) and will re-fetch the list, displaying it once it's downloaded. How can I most elegantly handle this "invalidated" state (hopefully without actual state)?

I also want to be able to expire the "cached" list on a timeout - basically, the deck list signal would give the cached list until enough time has passed, at which point it'll lazily make a network request before providing the data.

I have a couple ideas of how to implement these two things, but they seem a bit convoluted. Would love to get some guidance or be pointed in the direction of some example project.

A simple way I can see to handle this is to have a service layer that is imperative, and handle the caching and cache invalidation imperatively and using broadcast events to invalidate the cache and either return from cache or spawn a network request to populate the cache when the reactive layer tries to access the data. I'd rather not defer to this method without first understanding the reactive way of doing this.

Thanks!

like image 764
aehlke Avatar asked Oct 27 '13 19:10

aehlke


1 Answers

Answer copied from GitHub

The answer could go many ways, the setup doesn't offer much constraints. That being, I'll make some suggestions to get the conversation started.

First, have a look at +merge:, which allows you to combine a collection of signals by "funneling" their values into a single signal.

RACSignal *deckInvalidated = [[RACSignal merge:@[
    userDidSomethingSignal,
    appReawokenSignal,
    // etc
]];

With that in place, we need to transform that signal into one that fetches decks from the server whenever an invalidation event occurs.

Before we can do that, let's look at what the signal request looks like. Let's assume you have an RACified API client.

RACSignal *fetchDecks = [[APIClient fetchDecks] startWith:nil];

The use of -startWith: is a bit of forward thinking at this point. The plan is to form a signal that will be "bound" to a property using the RAC macro, and by using startWith:nil, that property will be set to nil whenever a new request will begin. This is to follow your requirement:

show nothing at first (rather than show the old list, since it knows it's out of date due to the user's action) and will re-fetch the list

Now we're in a position to map invalidation events into a network request, and it would look pretty simple, but it lacks some things.

RAC(self, decks) = [[deckInvalidated mapReplace:fetchDecks] switchToLatest];

This lacks any expiration refreshing. In order to do that, let's make a request signal that -repeats after an appropriate -delay following the completion of the preceding request:

RACSignal *delay = [[RACSignal empty] delay:AEDeckRefreshTimeout];

RACSignal *repeatingFetchDecks = [[fetchDecks concat:delay] repeat];

Now, revisiting the RAC assignment, it only needs to be slightly modified:

RAC(self, decks) = [[deckInvalidated mapReplace:repeatingFetchDecks] switchToLatest];

There's still an issue with this, the possibility that invalidation events cause concurrent requests to the server. You didn't mention this as a concern, so not sure if this is necessary/important for your app's use cases, but is something to consider.

For a complete overview, the code can be done in a single signal composition:

RAC(self, decks) = [[[RACSignal
    merge:@[
        userDidSomethingSignal,
        appReawokenSignal,
    ]]
    mapReplace:[[[[APIClient
        fetchDecks]
        startWith:nil]
        concat:[[RACSignal
            empty]
            delay:AEDeckRefreshTimeout]]
        repeat]]
    switchToLatest];
like image 84
Dave Lee Avatar answered Sep 27 '22 19:09

Dave Lee