انتقل إلى المحتوى الرئيسي

Whoop iOS Dependency Injection

· 4 دقائق قراءة
Jack Rosen

At WHOOP, we are always working on improving developer efficiency through designing simple APIs within re-usable code. In our iOS codebase, we started to migrate from a monolithic app target to a suite of Swift Packages to help modularize our codebase and speed up our build times. As we started to modularize, we ran into a roadblock: how do we properly hide implementation details for our shared classes while keeping dependency management simple?

To build modules that can be updated as business requirements change, it is important to properly encapsulate your class hierarchy, but without a simple way of injecting our dependencies, we had to make all classes in our dependency trees public to be constructed. To work around this challenge, we started to investigate integrating dependency injection frameworks that could cut down on the boilerplate. While there were some strong candidates, we found none of them fully met the needs of our team. We had the following requirements that were critical for us:

  1. Easily separating dependencies between modules.
  2. Simple enough for any iOS developer on our team to understand.

WhoopDI

Rather than settling for a framework that didn’t quite fit, we built WhoopDI —our own lightweight DI framework designed to solve these exact issues. This framework is designed to be flexible to your use cases, allowing you to define a dependency without writing any extra code. To show how simple it is to use, I’ll give an example use case in the WHOOP app. In the WHOOP iOS app, we have many features that interact directly with the WHOOP strap To encapsulate each different command, we make wrapping structs that perform the specific command. Here is an example of how it might look:

// Example WHOOP Strap implementation interacting with bluetooth
// Before WhoopDI, we need to make this public, not allowing us to change the implementation/dependencies easily
struct WhoopStrap {
let bluetoothManager: BluetoothManager
...
}

// Example command to send to the strap
public struct TurnOnRealtimeDataCommand {
let whoopStrap: WhoopStrap

func performRequest() async throws { ... }
}

Before using WhoopDI, we would need to expose the WhoopStrap class publicly, leading to tight coupling of internal implementation details of the WHOOP Strap and every feature that needed to use it. With WhoopDI and the @Injectable macro, this problem is solved:

// Example WHOOP Strap implementation interacting with bluetooth
struct WhoopStrap {
let bluetoothManager: BluetoothManager
...
}

@Injectable
// Example command to send to the strap
public struct TurnOnRealtimeDataCommand {
let whoopStrap: WhoopStrap

func performRequest() async throws { ... }
}

When using @Injectable, WhoopDI automatically adds the TurnOnRealtimeDataCommand struct to the dependency graph without any manual work needed. To use it, you need to call WhoopDI.inject(). Here is an example of usage:

struct TurningOnRealtimeDataView: View {
let command: TurnOnRealtimeDataCommand

var body: some View {
Button("Turn On!") {
Task { try await command.performRequest() }
}
}
}
extension UIViewController {
func showView() {
let view = TurningOnRealtimeDataView(command: WhoopDI.inject())
self.present(UIHostingController(rootView: view))
}
}

In the showView function, we create the command using WhoopDI.inject(). No manual dependency wiring is needed. Just use WhoopDI.inject() and the framework does the rest.

For cases where you need to define a single shared instance, such as when connecting a single WHOOP strap, you can create a custom module and register it directly with WhoopDI. Here is an example of usage:

final class BluetoothModule: DependencyModule {
override func defineDependencies() {
// Define the whoop strap as singleton
singleton {
// Use module to get dependencies
try WhoopStrap(bluetoothManager: self.get())
}
}
}

// Later
func addBluetoothModule() {
WhoopDI.registerModules([BluetoothModule()])
}

As you can see, we need to subclass DependencyModule and define all dependencies in the defineDependencies function. This can also be useful when providing a named dependency or if you have custom initialization logic. Calling the self.get() method inside of the dependency allows us to source the dependencies of the given type within the DI system. From there, we can register this module in WhoopDI using the registerModules function on the WhoopDI class.

For more complex use cases, please take a look at the README, which goes into other ways of using the framework.

Open Source

As we continue to work on innovative solutions, the WHOOP Software org will continue to give back to the community through open-sourcing our code. WhoopDI is designed to make dependency injection effortless, no matter the complexity of the app or data model. Whether you’re looking to simplify DI in your own projects or want to collaborate with us, check out the Github repo and leave us a star if you find it useful! We can’t wait to hear your feedback!

If you are interested in solving complex problems similar to the above, we are hiring!