I have a field where the user begins typing the name of a prescription drug and after 3 characters it searches through a 4.5mb JSON file containing over 36k entries. There are only two fields in the struct holding the decoded JSON (name and id) and I am doing a simple ForEach. However it is extremely slow and I need to make it real-time. This is a local JSON file in the app (i.e. I am not calling to an external URL).
There are a few issues I need to resolve:
- I need to make this as responsive as possible
- I need to return the results sorted by the name, but including numbers (i.e. Tylenol 50MG should come before Tylenol 100MG).
For issue #2 I tried using compare, which works but slowed things down even further, as follows:
var allDrugsSorted: [Mod_Prescription_DrugsList] {
return Mod_Prescription_DrugsList.allDrugs.sorted { first, second in
first.name.compare(second.name, options: .numeric) == .orderedAscending
} // End Return
}
This is my ForEach:
if isFocused == .prescription_name &&
viewModel.prescriptionName.count >= 3 {
ForEach(
viewModel.allDrugsSorted.filter {
$0.name
.lowercased()
.hasPrefix(
viewModel.prescriptionName.lowercased()
)
}.prefix(5), id: \.self) { prescription in
Button {
isFocused = nil
} label: {
VStack(alignment: .leading) {
Text(LocalizedStringKey(prescription.name))
}
.frame(maxWidth: .infinity, alignment: .leading)
} // End Label
.contentShape(Rectangle())
.padding()
} // End ForEach
} // End If
I already tried the .id(UUID()) trick and it did nothing to help.
This is the struct in which I am loading the JSON:
struct Mod_Prescription_DrugsList: Codable, Hashable {
let rxcui: String
let name: String
static let allDrugs = Bundle.main.decode([Mod_Prescription_DrugsList].self, from: "Drugs.json")
static let example = allDrugs[0]
}
This is the decode extension on Bundle:
extension Bundle {
func decode<T: Decodable>(
_ type: T.Type,
from file: String,
dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .deferredToDate,
keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys
) -> T {
guard let url = self.url(
forResource: file,
withExtension: nil
) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(
contentsOf: url
) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecodingStrategy
decoder.keyDecodingStrategy = keyDecodingStrategy
do {
return try decoder.decode(T.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
fatalError("Failed to decode \(file) from bundle due to missing key '\(key.stringValue)' - \(context.debugDescription)")
} catch DecodingError.typeMismatch(_, let context) {
fatalError("Failed to decode \(file) from bundle due to type mismatch - \(context.debugDescription)")
} catch DecodingError.valueNotFound(let type, let context) {
fatalError("Failed to decode \(file) from bundle due to missing \(type) value - \(context.debugDescription)")
} catch DecodingError.dataCorrupted(_) {
fatalError("Failed to decode \(file) from bundle because it appears to be invalid JSON")
} catch {
fatalError("Failed to decode \(file) from bundle - \(error.localizedDescription)")
}
}
}
I also tried creating a decoded array in the view model on init and though it speeds things up a little, it's still not great, and it's unusable because the view now takes almost a minute to load. self.allDrugsArray is an @Published var in the view model. Here's the function I wrote:
func getAllDrugs() {
let sortedDrugs = Mod_Prescription_DrugsList.allDrugs.sorted { first, second in
first.name.compare(second.name, options: .numeric) == .orderedAscending
}
sortedDrugs.forEach { drug in
self.allDrugsArray.append(drug)
}
}
CodePudding user response:
You really need to restructure this. You load your List, sort it and filter it every time your View needs to update. You should keep this list in a sorted state in Memory and filter it as you need.
Following architecture should work:
import Combine
class Viewmodel: ObservableObject{
//used to show the results
@Published var sortedAndFiltered: [Mod_Prescription_DrugsList] = []
//used to store the list
@Published var sorted: [Mod_Prescription_DrugsList] = []
//filter string
@Published var prescriptionName: String = ""
//Load, sort and assign the items here
init(){
$prescriptionName
.debounce(for: 0.4, scheduler: RunLoop.main) //Wait for user to stop typing
.receive(on: DispatchQueue.global()) // perform filter on background
.map{[weak self] filterString in
guard filterString.count > 3, let self = self else{
return []
}
//n apply the filter
return self.sorted.filter {
$0.name
.lowercased()
.hasPrefix(
filterString.lowercased()
)
}
}
.receive(on: RunLoop.main) // switch back to uithread
.assign(to: &$sortedAndFiltered)
}
func load(){
sorted = Mod_Prescription_DrugsList.allDrugs.sorted { first, second in
first.name.compare(second.name, options: .numeric) == .orderedAscending
}
}
}
struct ContentView: View{
@FocusState var isFocused: Bool
@StateObject private var viewmodel: Viewmodel = Viewmodel()
var body: some View{
VStack{
if isFocused &&
viewmodel.sortedAndFiltered.count >= 3 {
ForEach(viewmodel.sortedAndFiltered, id: \.self) { prescription in
Button {
isFocused = false
} label: {
VStack(alignment: .leading) {
Text(LocalizedStringKey(prescription.name))
}
.frame(maxWidth: .infinity, alignment: .leading)
} // End Label
.contentShape(Rectangle())
.padding()
} // End ForEach
} // End If
}.onAppear{ // I had to add a VStack to be able to do this just add it to the element surrounding your ForEach
// If list is empty load it in the background, so your app stays responsive
if viewmodel.sorted.count == 0{
Task{
viewmodel.load()
}
}
}
}
}
This is a more generalized approach so you will have to integrate this solution in your work. I have commented as much as I thought would be necessary. If you have questions don´t hessitate to ask. I didn´t have the chance to test this, so there may be some minor things to adress.