Reputation: 1251
I'm using ReactiveSwift + SDWebImage to download/cache userAvatars of an API and then I display them in my ViewControllers.
I have multiple ViewControllers which want to display the userAvatar, then they listen to its async loading.
What is the best way for me to implement the flow described below?
The flow I would like to create here is:
ViewControllerA
want to access userAvatarViewControllerA
listens for userAvatar signalsViewControllerA
temporarily display a placeholderViewControllerB
want to access userAvatarViewControllerB
listens for userAvatar signalsViewControllerB
temporarily display a placeholderUIImageView
with the fresh imageThis is my actual code:
class ViewControllerA {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ... Cell creation
// type(of: user) == User.self (see class User below)
user.loadAvatarImage()
disposable = user.image.producer
.observe(on: UIScheduler())
.startWithValues { image in
// image is is either a placeholder or the real avatar
cell.userImage.image = image
}
}
}
class ViewControllerB {
override func viewDidLoad() {
super.viewDidLoad()
// type(of: user) == User.self (see class User below)
user.loadAvatarImage()
disposable = user.image.producer
.observe(on: UIScheduler())
.startWithValues { image in
// image is is either a placeholder or the real avatar
headerImageView.image = image
}
}
}
class User: Mappable {
// ... User implementation
let avatarImage = MutableProperty<UIImage?>(nil)
// To call before accessing avatarImage.value
func loadAvatarImage() {
getAvatar { image in
self.avatarImageProperty.value = image
}
}
private func getAvatar(completion: @escaping ((UIImage) -> Void)) {
// ... Async image download
competion(image)
}
}
I don't find that calling user.loadAvatarImage()
before listening to the signal is very clean...
I know my code isn't so "Reactive", I still new with Reactive concept. Feel free to criticize, I'm trying to improve myself
Thanks in advance for your advice.
Upvotes: 1
Views: 417
Reputation: 804
The best way to handle this situation is to create a SignalProducer
that:
if image
is already downloaded when the SignalProducer
is started: immediately emits .value(image)
followed by .completed
if image
is currently downloading when the SignalProducer
is started: when image
is finished downloading, emits .value(image)
followed by .completed
if image
has not been downloaded and is not currently downloading when the SignalProducer
is started: initiates download of image
, and when image
is finished downloading emits .value(image)
followed by .completed
ReactiveSwift provides us with a "manual" constructor for signal producers that allows us to write imperative code that runs every time the signal producer is started:
private let image = MutableProperty<UIImage?>(.none)
private var imageDownloadStarted = false
public func avatarImageSignalProducer() -> SignalProducer<UIImage, NoError> {
return SignalProducer { observer, lifetime in
//if image download hasn't started, start it now
if (!self.imageDownloadStarted) {
self.imageDownloadStarted = true
self.getAvatar { self.image = $0 }
}
//emit .value(image) followed by .completed when the image has downloaded, or immediately if it has already downloaded
self.image.producer //use our MutableProperty to get a signalproducer for the image download
.skipNil() //dont send the nil value while we wait for image to download
.take(first: 1) //send .completed after image value is sent
.startWithSignal { $0.observe(observer) } //propogate these self.image events to the avatarImageSignalProducer
}
}
To make your code even more "reactive" you can use the ReactiveCocoa library to bind your avatarImageSignalProducer
to the UI:
ReactiveCocoa does not come with a BindingTarget
for UIImageView.image
built in, so we write an extension ourselves:
import ReactiveCocoa
extension Reactive where Base: UIImageView {
public var image: BindingTarget<UIImage> {
return makeBindingTarget { $0.image = $1 }
}
}
this lets us use the ReactiveCocoa binding operators in the ViewControllers to clean up our code in viewDidLoad
/cellForRowAtIndexPath
/etc like so:
class ViewControllerA {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ... Cell creation
cell.userImage <~ user.avatarImageSignalProducer()
.take(until: cell.reactive.prepareForReuse) //stop listening to signal & free memory when cell is reused before image loads
}
}
class ViewControllerB {
override func viewDidLoad() {
headerImageView.image <~ user.avatarImageSignalProducer()
.take(during: self.reactive.lifetime) //stop listening to signal & free memory when VC is deallocated before image loads
}
}
It is also important to think about memory & cyclical references when binding data to the UI that is not referenced in memory by the viewcontroller (e.g. if our User is a global variable that stays in memory after the VC is deallocated rather than a property of the VC). When this is the case we must explicitly stop listening to the signal when the VC is deallocated, or its memory will never be freed. The calls to .take(until: cell.reactive.prepareForReuse)
and .take(during: self.reactive.lifetime)
in the code above are both examples of explicitly stopping a signal for memory management purposes.
Upvotes: 1