Lightweight Observable is a simple implementation of an observable sequence that you can subscribe to. The framework is designed to be minimal meanwhile convenient. The entire code is only ~100 lines (excluding comments). With Lightweight Observable you can easily set up UI-Bindings in an MVVM application, handle asynchronous network calls and a lot more.
The code was heavily influenced by roberthein/observable. However I needed something that was syntactically closer to RxSwift, which is why I came up with this code, and for re-usability reasons afterwards moved it into a CocoaPod.
If you want to update from version 1.x.x, please have a look at the Lightweight Observable 2.0 Migration Guide
To run the example project, clone the repo, and open the workspace from the Example directory.
- Swift 5.5
- Xcode 13.2+
- iOS 9.0+
In case your minimum required version is greater equal iOS 13.0, I recommend using Combine instead of adding Lightweight Observable
as a dependency.
If you rely on having a current and previous value in your subscription closure, please have a look at this extension: Combine+Pairwise.swift.
Update: Since version 2.2
an Observable
instance conforms to the Publisher
protocol from Swift's Combine
๐
This makes transitioning from LightweightObservable
to Combine
a lot easier, as you can use features from Combine
without having to change the underlying Observable
to a Publisher
.
Example Code for using Combine
functions on an instance of PublishSubject
:
var subscriptions = Set<AnyCancellable>()
let publishSubject = PublishSubject<Int>()
publishSubject
.map { $0 * 2 }
.sink { print($0) }
.store(in: &subscriptions)
publishSubject.update(1) // Prints "2"
publishSubject.update(2) // Prints "4"
publishSubject.update(3) // Prints "6"
Cheatsheet
LightweightObservable |
Combine |
---|---|
PublishSubject |
PassthroughSubject |
Variable |
CurrentValueSubject |
Furthermore, using the property values
of a Combine.Publisher
, you can use an Observable
in an asynchronous sequence:
for await value in observable.values {
// ...
}
CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate Lightweight Observable into your Xcode project using CocoaPods, specify it in your Podfile
:
pod 'LightweightObservable', '~> 2.0'
Carthage is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks. To integrate Lightweight Observable into your Xcode project using Carthage, specify it in your Cartfile
:
github "fxm90/LightweightObservable" ~> 2.0
Run carthage update to build the framework and drag the built LightweightObservable.framework
into your Xcode project.
The Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the swift
compiler. It is in early development, but Lightweight Observable does support its use on supported platforms.
Once you have your Swift package set up, adding Lightweight Observable as a dependency is as easy as adding it to the dependencies
value of your Package.swift
.
dependencies: [
.package(url: "https://github.com/fxm90/LightweightObservable", from: "2.0.0")
]
The framework provides three classes Observable
, PublishSubject
and Variable
:
Observable
: An observable sequence that you can subscribe to, but not change the underlying value (immutable). This is useful to avoid side-effects on an internal API.PublishSubject
: Subclass ofObservable
that starts empty and only emits new elements to subscribers (mutable).Variable
: Subclass ofObservable
that starts with an initial value and replays it or the latest element to new subscribers (mutable).
A PublishSubject
starts empty and only emits new elements to subscribers.
let userLocationSubject = PublishSubject<CLLocation>()
// ...
userLocationSubject.update(receivedUserLocation)
A Variable
starts with an initial value and replays it or the latest element to new subscribers.
let formattedTimeSubject = Variable("4:20 PM")
// ...
formattedTimeSubject.value = "4:21 PM"
// or
formattedTimeSubject.update("4:21 PM")
Initializing an observable directly is not possible, as this would lead to a sequence that will never change. Instead you need to cast a PublishSubject
or a Variable
to an Observable.
var formattedTime: Observable<String> {
formattedTimeSubject
}
lazy var formattedTime: Observable<String> = formattedTimeSubject
A subscriber will be informed at different times, depending on the subclass of the corresponding observable:
PublishSubject
: Starts empty and only emits new elements to subscribers.Variable
: Starts with an initial value and replays it or the latest element to new subscribers.
Declaration
func subscribe(_ observer: @escaping Observer) -> Disposable
Use this method to subscribe to an observable via a closure:
formattedTime.subscribe { [weak self] newFormattedTime, oldFormattedTime in
self?.timeLabel.text = newFormattedTime
}
Please notice that the old value (oldFormattedTime
) is an optional of the underlying type, as we might not have this value on the initial call to the subscriber.
Important: To avoid retain cycles and/or crashes, always use [weak self]
when an instance of self
is needed by an observer.
Declaration
func bind<Root: AnyObject>(to keyPath: ReferenceWritableKeyPath<Root, Value>, on object: Root) -> Disposable
It is also possible to use Swift's KeyPath feature to bind an observable directly to a property:
formattedTime.bind(to: \.text, on: timeLabel)
When you subscribe to an Observable
the method returns a Disposable
, which is basically a reference to the new subscription.
We need to maintain it, in order to properly control the life-cycle of that subscription.
Let me explain you why in a little example:
Imagine having a MVVM application using a service layer for network calls. A service is used as a singleton across the entire app.
The view-model has a reference to a service and subscribes to an observable property of this service. The subscription-closure is now saved inside the observable property on the service.
If the view-model gets deallocated (e.g. due to a dismissed view-controller), without noticing the observable property somehow, the subscription-closure would continue to be alive.
As a workaround, we store the returned disposable from the subscription on the view-model. On deallocation of the disposable, it automatically informs the observable property to remove the referenced subscription closure.
In case you only use a single subscriber you can store the returned Disposable
to a variable:
// MARK: - Using `subscribe(_:)`
let disposable = formattedTime.subscribe { [weak self] newFormattedTime, oldFormattedTime in
self?.timeLabel.text = newFormattedTime
}
// MARK: - Using a `bind(to:on:)`
let disposable = dateTimeViewModel
.formattedTime
.bind(to: \.text, on: timeLabel)
In case you're having multiple observers, you can store all returned Disposable
in an array of Disposable
. (To match the syntax from RxSwift, this pod contains a typealias called DisposeBag
, which is an array of Disposable
).
var disposeBag = DisposeBag()
// MARK: - Using `subscribe(_:)`
formattedTime.subscribe { [weak self] newFormattedTime, oldFormattedTime in
self?.timeLabel.text = newFormattedTime
}.disposed(by: &disposeBag)
formattedDate.subscribe { [weak self] newFormattedDate, oldFormattedDate in
self?.dateLabel.text = newFormattedDate
}.disposed(by: &disposeBag)
// MARK: - Using a `bind(to:on:)`
formattedTime
.bind(to: \.text, on: timeLabel)
.disposed(by: &disposeBag)
formattedDate
.bind(to: \.text, on: dateLabel)
.disposed(by: &disposeBag)
A DisposeBag
is exactly what it says it is, a bag (or array) of disposables.
If you create an Observable which underlying type conforms to Equatable
you can subscribe to changes using a specific filter. Therefore this pod contains the method:
typealias Filter = (NewValue, OldValue) -> Bool
func subscribe(filter: @escaping Filter, observer: @escaping Observer) -> Disposable {}
Using this method, the observer will only be notified on changes if the corresponding filter matches (returns true
).
This pod comes with one predefined filter method, called subscribeDistinct
. Subscribing to an observable using this method will only notify the observer, if the new value is different from the old value. This is useful to prevent unnecessary UI-Updates.
Feel free to add more filters, by extending the Observable
like this:
extension Observable where T: Equatable {}
You can get the current value of the Observable
by accessing the property value
. However it is always better to subscribe to a given observable! This shortcut should only be used during testing.
XCTAssertEqual(viewModel.formattedTime.value, "4:20")
Using the given approach, your view-model could look like this:
final class ViewModel {
// MARK: - Public properties
/// The current date and time as a formatted string (**immutable**).
var formattedDate: Observable<String> {
formattedDateSubject
}
// MARK: - Private properties
/// The current date and time as a formatted string (**mutable**).
private let formattedDateSubject: Variable<String> = Variable("\(Date())")
private var timer: Timer?
// MARK: - Instance Lifecycle
init() {
// Update variable with current date and time every second.
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.formattedDateSubject.value = "\(Date())"
}
}
And your view controller like this:
final class ViewController: UIViewController {
// MARK: - Outlets
@IBOutlet private var dateLabel: UILabel!
// MARK: - Private properties
private let viewModel = ViewModel()
/// The dispose bag for this view controller. On it's deallocation, it removes the
/// subscription-closures from the corresponding observable-properties.
private var disposeBag = DisposeBag()
// MARK: - Public methods
override func viewDidLoad() {
super.viewDidLoad()
viewModel
.formattedDate
.bind(to: \.text, on: dateLabel)
.disposed(by: &disposeBag)
}
Feel free to check out the example application as well for a better understanding of this approach ๐
Felix Mau (me(@)felix.hamburg)
LightweightObservable is available under the MIT license. See the LICENSE file for more info.