I noticed this was triggering the deinit block even without weak/unowned used. I simplified the code to show what is happening.
final class ViewModel: ObservableObject {
#Published var isLoading = false
private var cancellables: [AnyCancellable] = []
var randomPublisher: AnyPublisher<Void, Never> {
Just(()).eraseToAnyPublisher()
}
func someAction() {
randomPublisher.sink { completion in
self.isLoading = false
switch completion {
case .finished:
break
}
} receiveValue: { _ in }
.store(in: &cancellables)
}
}
struct SampleView: View {
#StateObject private var viewModel = ViewModel()
}
I would think there is a reference cycle when someAction() is called as self is captured inside the subscription and the viewmodel holds the subscription array. It's successfully accessing the deinit block when view is dismissed so why is that the case whereas in other viewmodels I need to make self weak in the same place?
Related
I am listening for changes of a publisher, then fetching some data asynchronously in my pipeline and updating the view with the result. However, I am unsure how to make this testable. How can I best wait until the expectation has been met?
View
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
var body: some View {
NavigationView {
List(viewModel.results, id: \.self) {
Text($0)
}
.searchable(text: $viewModel.searchText)
}
}
}
ViewModel
final class ContentViewModel: ObservableObject {
#Published var searchText: String = ""
#Published var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
observeSearchText()
}
func observeSearchText() {
$searchText
.dropFirst()
.debounce(for: 0.8, scheduler: DispatchQueue.main)
.sink { _ in
Task {
await self.fetchResults()
}
}.store(in: &cancellables)
}
private func fetchResults() async {
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
self.results = ["01", "02", "03"]
} catch {
//
}
}
}
Tests
class ContentViewTests: XCTestCase {
func testExample() {
// Given
let viewModel = ContentViewModel()
// When
viewModel.searchText = "123"
// Then (FAILS - Not waiting properly for result/update)
XCTAssertEqual(viewModel.results, ["01", "02", "03"])
}
}
Current Workaround
If I make fetchResults() available I can async/await which works for my unit and snapshot tests, but I was worried that:
It is bad practice to expose if it isn't to be called externally?
I'm not testing my publisher pipeline
func testExample_Workaround() async {
// Given
let viewModel = ContentViewModel()
// When
await viewModel.fetchResults()
// Then
XCTAssertEqual(viewModel.results, ["01", "02", "03"])
}
You need to wait asynchronously via expectation and check result via publisher.
Here is possible approach. Tested with Xcode 13.2 / iOS 15.2
private var cancelables = Set<AnyCancellable>()
func testContentViewModel() {
// Given
let viewModel = ContentViewModel()
let expect = expectation(description: "results")
viewModel.$results
.dropFirst() // << skip initial value !!
.sink {
XCTAssertEqual($0, ["01", "02", "03"])
expect.fulfill()
}
.store(in: &cancelables)
viewModel.searchText = "123"
wait(for: [expect], timeout: 3)
}
I created a little demo app to demonstrate my problem:
I have a model that has an Int which gets incremented every second. A ViewModel is observed by the view and converts this Int to a String which should be displayed.
The problem is that I see the incrementation in the console but the UI is not getting updated. Where is the problem? I used the same approach in other apps. Is the timer the problem?
I'm aware that the naming is not good, it is just for simplicity here. Heartbeatemitter is a separate class because the Timer needs it and I will use in different views inside my app where I pass around the same instance. Why is my Viewmodel not recognising the change of the model?
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
Text(viewModel.number)
}
}
class ViewModel: ObservableObject{
#Published private var model: Model
init() {
model = Model(emitter: HeartbeatEmitter())
model.heartbeatEmitter.delegate = model
}
var number: String {
"\(model.number)"
}
}
protocol Heartbeat {
mutating func beat()
}
struct Model: Heartbeat {
var heartbeatEmitter: HeartbeatEmitter
var number: Int = 0
init(emitter: HeartbeatEmitter){
self.heartbeatEmitter = emitter
}
mutating func beat() {
number += 1
print(number)
}
}
class HeartbeatEmitter {
private var timer: Timer!
var delegate: Heartbeat?
init() {
setupTimer()
}
func setupTimer() {
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(notifyDelegate), userInfo: nil, repeats: true)
RunLoop.main.add(timer, forMode: .common)
}
#objc
func notifyDelegate() {
delegate?.beat()
}
}
I wouldn't rely on a mutating member of the struct to project the changes to the #Published wrapper. I'd use something that explicitly gets/sets the model.
This, for example, works:
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
Text(viewModel.number)
}
}
class ViewModel: ObservableObject, HeartbeatModifier {
#Published private var model = Model()
var emitter = HeartbeatEmitter()
init() {
emitter.delegate = self
}
func beat() {
model.number += 1
}
var number: String {
"\(model.number)"
}
}
protocol HeartbeatModifier {
func beat()
}
protocol Heartbeat {
var number : Int { get set }
}
struct Model: Heartbeat {
var number: Int = 0
}
class HeartbeatEmitter {
private var timer: Timer!
var delegate: HeartbeatModifier?
init() {
setupTimer()
}
func setupTimer() {
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(notifyDelegate), userInfo: nil, repeats: true)
RunLoop.main.add(timer, forMode: .common)
}
#objc
func notifyDelegate() {
delegate?.beat()
}
}
Model is a struct, which makes it a value type. That means that when you pass a Model to a function, you're actually making a copy of it and passing that copy to the function.
In particular, this line makes a copy of model:
model.heartbeatEmitter.delegate = model
The “function call” is the call to the setter of delegate. So the delegate property of the HeartbeatEmitter stores its own copy of Model, which is completely independent of the copy stored in the model property of the ViewModel. The Model stored in the HeartbeatEmitter's delegate is being modified, but the Model stored in the ViewModel's model property is not being modified.
I recommend you move the responsibility for side effects (like events based on the passage of time) out of your model object and into a control object: in this case, your ViewModel.
Furthermore, your code will be simpler if you use the Combine framework's Timer.Publisher to emit your time-based events, instead of hand-rolling your own Heartbeat and HeartbeatEmitter types.
Finally, this line also has a problem:
#ObservedObject var viewModel = ViewModel()
The problem here is that you are not being honest with SwiftUI about the lifetime of viewModel and so it may be replaced with a new instance of ViewModel when you don't expect. You should be using #StateObject here. Read this article by Paul Hudson for more details.
Thus:
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
Text(viewModel.number)
}
}
class ViewModel: ObservableObject{
#Published private var model: Model
private var tickets: [AnyCancellable] = []
init() {
model = Model()
Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in self?.model.beat() }
.store(in: &tickets)
}
var number: String {
"\(model.number)"
}
}
struct Model {
var number: Int = 0
mutating func beat() {
number += 1
print(number)
}
}
I use Combine in viewModels to update the views. But if I store the AnyCancellable objects into a set of AnyCancellable, the deinit method is never called. I use the deinit to cancel all cancellables objects.
struct View1: View {
#ObservedObject var viewModel:ViewTextModel = ViewTextModel()
#Injected var appActions:AppActions
var body: some View {
VStack {
Text(self.viewModel.viewText)
Button(action: {
self.appActions.goToView2()
}) {
Text("Go to view \(self.viewModel.viewText)")
}
}
}
}
class ViewTextModel: ObservableObject {
#Published var viewText: String
private var cancellables = Set<AnyCancellable>()
init(state:AppState) {
// initial state
viewText = "view \(state.view)"
// updated state
state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
}
deinit {
cancellables.forEach { $0.cancel() }
}
}
Each time the view is rebuilt, a new viewmodel is instantiated but the old one is not destroyed. viewText attribute is updated on each instance with state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
If I don't store the cancellable object in the set, deinit is called but viewText is not updated if the state's changed for the current view.
Do you have an idea of how to manage the update of the state without multiplying the instances of the viewmodel ?
Thanks
You could use sink instead of assign:
state.$view
.removeDuplicates()
.sink { [weak self] in self?.viewText = $0 }
.store(in: &cancellables)
But I question the need for Combine here at all. Just use a computed property:
class ViewTextModel: ObservableObject {
#Published var state: AppState
var viewText: String { "view \(state.view)" }
}
UPDATE
If your deployment target is iOS 14 (or macOS 11) or later:
Because you are storing to an #Published, you can use the assign(to:) operator instead. It manages the subscription for you without returning an AnyCancellable.
state.$view
.removeDuplicates()
.map { "view \($0)" }
.assign(to: &$viewText)
// returns Void, so nothing to store
I have a strange problem with the SwiftUI Alert view. In an ObservableObject, I do some network requests and in case of a error I will show a alert. This is my simplified model:
class MyModel: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
#Published var isError: Bool = false
public func network() {
Service.call() {
self.isError = true
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
}
Service.call is a dummy for my network request. My view looks like:
struct MyView: View {
#ObservedObject var model: MyModel
var body: some View {
…
.alert(isPresented: self.$model.isError) {
print("Error Alert")
return Alert(title: Text("Alert"))
}
}
}
On the first call, everything works and the alert is shown. For all further calls,print("Error Alert") will be executed and Error Alert appears in the console, but the alert is not shown.
Does anyone have any idea why Alert is only shown once?
Try to use instead (there is already default publisher for #Published properties)
class MyModel: ObservableObject {
#Published var isError: Bool = false
public func network() {
Service.call() {
DispatchQueue.main.async {
self.isError = true // << !!! important place to call
}
}
}
}
I have an ObservableObject view model with a #Published variable that's an enum indicating the status of the viewModel:
class BatchesViewModel: ObservableObject {
#Published private var lookupStatus: BatchLookupStatus = .none
...
}
enum BatchLookupStatus {
case none
case lookingUp(searchText: String)
case error(errorMessage: String)
case validated(batch: Batch)
}
I set the status when there's an error, or there's a validation, etc. I set the status back to .none when the error is dismissed.
Everything works fine, except I get a EXC_BAD_ACCESS crash randomly.
I wanted to see what the status was when that happens, so I put a print statement when the status is set:
#Published private var lookupStatus: BatchLookupStatus = .none {
didSet {
print(_lookupStatus)
}
}
I noticed that with that print statement, the crash no longer happens. In fact, if I just leave an empty didSet, it prevents the crash as well:
#Published private var lookupStatus: BatchLookupStatus = .none { didSet {} }
As soon as I remove the didSet, it crashes again.
What in the world is going on?
I assume the reason is not in the provided model, because as tested it all works as expected - I mean with Published-Refresh flow.
Here is an example of your model usage I did:
import SwiftUI
import Combine
class BatchesViewModel: ObservableObject {
#Published var lookupStatus: BatchLookupStatus = .none
}
enum BatchLookupStatus {
case none
case lookingUp(searchText: String)
case error(errorMessage: String)
}
struct TestPublishCrashed: View {
#ObservedObject var viewModel = BatchesViewModel()
var body: some View {
VStack {
Button(action: {
self.viewModel.lookupStatus = .error(errorMessage: "Search failed")
}) {
Text("Emulate Error").padding().background(Color.yellow)
}.padding()
Button(action: {
self.viewModel.lookupStatus = .lookingUp(searchText: "hello world")
}) {
Text("Emulate Query").padding().background(Color.yellow)
}.padding()
status.padding()
}
}
var status: some View {
switch viewModel.lookupStatus {
case .none:
return Text("No status")
case .error(let error):
return Text("Got error: \(error)")
case .lookingUp(let query):
return Text("Searching: \(query)")
}
}
}
struct TestPublishCrashed_Previews: PreviewProvider {
static var previews: some View {
TestPublishCrashed()
}
}