Running into crash that I can't seem to root cause. Before I dive into the core crash, let me try to explain as best I can the architecture we have as I feel like an aspect or piece of it could be exacerbating the issue.
Our app is a modularized, CMS driven app where each module represents a piece of content on a page. We're using MVVM, and each module consists of a struct model, a view model, and a SwiftUI view. Module's can themselves contain other modules and so on, which makes them container modules. When we get a JSON response, it represents the page (itself a container module) and all the modules that make it up, which as per above could include some container or non-container modules depending on how it is configured in the CMS.
Example of a page structure could look something like this:
- Page (Container Module)
- Module 1
- Module 2
- Module 3 (Container Module)
- Module 4
- Module 5 (Container Module)
- Module 7
- Module 8
- Module 9
- Module 6
- Module 10
The deserialization process of JSON to model-> view model -> view happens with some providers. We start by decoding from JSON to model, using a moduleName
field in each module to direct us to its equivalent struct model. Example below:
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let moduleName = try container.decode(String.self, forKey: .moduleName)
let spacerValue = try? container.decode(String.self, forKey: .spacerValue)
do {
switch moduleName {
case "Module1":
self.init(try Module1(from: decoder), spacerValue: spacerValue)
case "Module2":
self.init(try Module2(from: decoder), spacerValue: spacerValue)
case "Module3":
self.init(try Module3(from: decoder), spacerValue: spacerValue)
default:
self.init(UnknownModule(id: "Invalid module type: \(moduleName)", dataCapture: nil, meta: nil))
}
} catch {
self.init(UnknownModule(id: "Error decoding module \(moduleName): \(error)", dataCapture: nil, meta: nil))
}
}
From there we have some view model and view providers that look something like this:
public class ModuleViewModelProvider {
public static func createViewModel(content: Module?, host: ContentHost?) -> ViewModel? {
guard let content = content else {
return nil
}
let vm: ViewModel?
switch content {
case is Module1:
vm = Module1ViewModel(content as! Module1, host: host)
case is Module2:
vm = Module2ViewModel(content as! Module2, host: host)
case is Module3:
vm = Module3ViewModel(content as! Module3, host: host)
default:
vm = nil
}
(vm as? ModuleHolder)?.triggerUpdate()
return vm
}
}
public class ModuleViewProvider {
public static func createView(viewModel: ViewModel) -> AnyView {
if let viewModel = viewModel as? BaseObservableViewModel {
var view: AnyView
switch viewModel {
case is Module1ViewModel:
view = AnyView(Module1View(viewModel: viewModel as! Module1ViewModel))
case is Module2ViewModel:
view = AnyView(Module2View(viewModel: viewModel as! Module2ViewModel))
case is Module3ViewModel:
view = AnyView(Module3View(viewModel: viewModel as! Module3ViewModel))
...
Every view model has a base class which looks like the below:
public class BaseObservableViewModel: ViewModel, ObservableObject {
weak private(set) var host: ContentHost?
@Published var needsRefresh = false
init(host: ContentHost?) {
self.host = host
}
func updateHost(host: ContentHost?) {
self.host = host
}
}
public class BaseModuleViewModel<T: Module>: BaseObservableViewModel, Identifiable {
@Published var module: T {
willSet { objectWillChange.send() }
}
var tracked = false
public var id: String? {
module.id
}
private var refreshed = false {
didSet {
needsRefresh = (module as? Refreshable)?.needsRefresh ?? false && !refreshed
}
}
init(module: T, host: ContentHost?) {
self.module = module
super.init(host: host)
self.needsRefresh = (module as? Refreshable)?.needsRefresh ?? false && !refreshed
fetchData()
}
private func fetchData() {
if let initialAction = module.meta?.initAction { // EXC_BAD_ACCESS happens here on module being accessed
action(
action: initialAction,
onComplete: { [weak self] in
self?.refreshed = true
}
)
} else if (self.needsRefresh == true) {
self.fetchContent(
onError: { [weak self] _ in
self?.refreshed = true
},
onComplete: { [weak self] (module: T) in
self?.refreshed = true
self?.update(module: module)
}
)
}
}
}
As noted by the code comment above, the EXC_BAD_ACCESS crash happens when accessing the module property in the fetchData() function, even though it was just set in the first line of the init. Weirdly, this only started happening recently and doesn't seem to be happening on simulator. It also doesn't seem to be consistently happening on every device/OS. When it does happen however, it consistently crashes in said device in the same place with the same page response (that admittedly is rather large and contains some nesting, but not so large that it should be alarming). One thing we've noted is that where it crashes, it always seems to be in some module inside a nested container module. Our team is frankly at a loss, any knowledge or insight on the matter that we might not be aware of would be greatly appreciated, and I can provide more info if required.
NOTE: we've been able to mitigate the issue by wrapping the fetchData()
call in a DispatchQueue.main.async
, but it doesn't seem full proof, nor do I think we should have to do that.