Dimillian / SwiftUIFlux

A very naive implementation of Redux using Combine BindableObject to serve as an example
Apache License 2.0
654 stars 62 forks source link

Dependency Management for Larger Apps #16

Open danhalliday opened 3 years ago

danhalliday commented 3 years ago

SwiftUIFlux works great for apps which only need a singleton or two in their service layer, since you can just reference the singletons in AsyncActions to perform network requests etc. For apps with a larger dependency graph, I’ve been thinking about how to connect async actions to services without relying on singletons — and at the same time trying to avoid the age-old pitfall of creating another giant complex dependency injection system, or turning SwiftUIFlux into something much more complex than it needs to be.

I wanted something where I could wire up my dependency graph as usual, and handle SwiftUIFlux Actions in one place:

actions.publisher(for: LogIn.self) // Catch a `LogIn` action
    .flatMapResult(userAuthenticator.logIn) // Log in using `userAuthenticator` and return a `Result`
    .sink(receiveValue: store.dispatch) // Dispatch the result back to the store
    .store(in: &subscriptions)

I put together a simple middleware which provides combine publishers for actions. The idea is you catch these actions and map them to service calls. The neat part is if you have your services also return Action publishers, you can then connect them back to the store for further dispatch.

Here’s a more full example showing an app with two dependencies (my app’s real dependency graph is obviously more complex and is several layers deep):

class AppContainer {

    let store: Store<AppState>

    private let actions: ActionPublisherMiddleware

    private let userAuthenticator: UserAuthenticator
    private let soundEffectsPlayer: SoundEffectsPlayer

    private var subscriptions: [AnyCancellable] = []

    init() {

        actions = ActionPublisherMiddleware()
        userAuthenticator = UserAuthenticator(/* Some dependency... */)
        soundEffectsPlayer = SoundEffectsPlayer(/* Some other dependency... */)

        store = AppStore(reducer: appReducer, middleware: [actions.handler], state: AppState())

        // Subscriptions

        actions.publisher(for: LogIn.self)
            .flatMapResult(userAuthenticator.logIn)
            .sink(receiveValue: store.dispatch)
            .store(in: &subscriptions)

        actions.publisher(for: LogInDidSucceed.self)
            .map { _ in SoundEffectsPlayer.Sound.bing }
            .sink(receiveValue: soundEffectsPlayer.play)
            .store(in: &subscriptions)

        actions.publisher(for: LogOut.self)
            .flatMapResult(userAuthenticator.logOut)
            .sink(receiveValue: store.dispatch)
            .store(in: &subscriptions)

    }

}

The middleware is trivial, and you could arrange it however you like (say as a free function with a global publisher if you preferred). I’ve done it as a class with a handler function:

class ActionPublisherMiddleware {

    private let publisher = PassthroughSubject<Action, Never>()

    func publisher<A>(for action: A.Type) -> AnyPublisher<A, Never> where A : Action {
        publisher
            .compactMap { $0 as? A }
            .eraseToAnyPublisher()
    }

    func handler(dispatch: @escaping DispatchFunction, getState: @escaping () -> FluxState?) -> (@escaping DispatchFunction) -> DispatchFunction {
        return { next in
            return { action in
                self.publisher.send(action)
                return next(action)
            }
        }
    }

}

The neat behaviour where the results of async service calls can be mapped back to the store dispatcher is also trivial — I used an overload on Store.dispatch that just dispatches from a result type where both cases are actions:

extension Store {
    func dispatch<S, F>(result: Result<S, F>) where S : Action, F : Action {
        switch result {
        case .success(let action): dispatch(action: action)
        case .failure(let action): dispatch(action: action)
        }
    }
}

I’ve started structuring my services to take and return actions. Actions are just plain values after all so it doesn’t pose any real problem for them to make their way into the service layer. Here’s an example:

// Service

class UserAuthenticator {
    func logIn(action: LogIn) -> AnyPublisher<LogInDidSucceed, LogInDidFail>
    func logOut(action: LogOut) -> AnyPublisher<LogOutDidSucceed, LogOutDidFail>
}

// Actions

struct LogIn: Action {
    let username: String
    let password: String
}

struct LogInDidSucceed: Action {
    let user: User
}

struct LogInDidFail: Action, Error {
    let error: Error
}

struct LogOut: Action {
    // ...
}

struct LogOutDidSucceed: Action {
    // ...
}

struct LogOutDidFail: Action, Error {
    let error: Error
}

I would be interested to hear if anyone else has also done something like the above. Are there any parts of this that would be suitable for inclusion in SwiftUIFlux? Would the Combine publisher middleware be general enough to be attached to the store (eg. store.publisher(for: <Action>.self)...)?