When there doesn't seem to be any other avenue of analysis, I resort to the humble fall-back of logging.
I have previously used the following technique in production iOS apps. This is a bit of work to set up, but once going it is tremendously useful for many other problems in the future. Not just crashes, but any other strange behaviour that users report which you cannot replicate in your testing environments.
- The very first thing the app should do is check if the PREVIOUS startup was successful or not by reading some values that should have been written to defaults at the beginning and end of the previous startup (details in the next step). If the PREVIOUS startup was NOT successful, give the user the option to run in some sort of 'safe mode' (what this means depends on what your app tries to do at startup, but for me it meant not loading any data, or doing anything much apart from displaying the UI devoid of any data-dependent items; for some apps, it could even go so far as to load a completely different UI which includes only diagnostics tools or data deletion/reset tools).
- The very next thing the app should do after determining that the the previous startup was OK (or that this is the first startup ever) is to write some sort of "startupBegan" status to defaults as soon as possible and then later some sort of "startupCompleted" status only when it has fully completed startup (what "fully completed startup" means is app-dependent, but you really want to be certain that the UI is fully responsive at this point, and is displaying everything it needs to; this can be a bit tricky to determine sometimes, as some things don't run until after the splash screen has disappeared, etc; if you cannot find any other way, I suppose you can trigger this with a timer, but that would be rather ugly - better to find some way to determine when startup really is fully completed). These values can be used to determine if the startup began, but did not complete and are what step 1 (above) uses to determine if the previous startup was successful or not.
- Include lots of logging in the app (my old custom Objective-C version of how to do this has been replaced with a more relevant Swift version in the "UPDATE" section below).
- Give the app the ability to email log files to your support email address - this must be available in the app's 'safe mode' (as well as in normal mode). In normal mode, I make this fairly obscure so as not to be noticed much by the user when all is well (eg, a button right at the bottom of a 'Settings' or 'About' view). I tell the user how to find the button when they've submitted a support request for which I really need the logs.
Many variations on this are possible. Including things like only enable logging if the user has configured a setting for it. You may have to add a whole lot of logging around particular areas of code sometimes when a user reports a particular problem, and then delete it again after the problem is resolved (if you are concerned about the performance/storage issues around logging).
For my (Objective-C) app, the places for including my code to write startup status to defaults were as below (there may be better places more suitable for your app):
- "startupBegan" early in the app delegate's
application:didFinishLaunchingWithOptions:
- "startupCompleted" at end of view controller's
viewDidAppear (NOT viewWillAppear! there's a lot of stuff can go wrong between these two being sent)
UPDATE:
Logging in iOS is a LOT better than it used to be when this post was first written, so I've removed the clunky custom Objective-C NSLog() and stdout file redirection instructions I originally had in this post. Instead I now (using Swift) do something like this, at the global level (eg, in AppDelegate):
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "app")
(Optionally, you can have multiple loggers which will identify themselves in each log line. I also have another one in one of my apps to which I redirecdt all JavaScript logging from a web view, like:
let jsLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "JS")
Then to actually write to the logs, use something like:
logger.log("Some message being logged: \(some variable, privacy: .public)")
Then to gather actual logs (eg, if the user hit's the 'Send Logs' button in a 'Diagnostics' view of the 'Settings' UI):
guard let logStore = try? OSLogStore(scope: .currentProcessIdentifier) else { return }
let oneHourAgo = logStore.position(date: Date().addingTimeInterval(-3600))
guard let allEntries = try? logStore.getEntries(at: oneHourAgo) else { return }
let filteredEntries = allEntries.compactMap { $0 as? OSLogEntryLog }.filter { $0.subsystem == Bundle.main.bundleIdentifier! }
var logText = ""
for entry in filteredEntries {
logText.append(contentsOf: "\(entry.date): \(entry.composedMessage)\n")
}
let logData = Data(logText.utf8)
Then attach the logData to an email ready to send to support (eg, using a MFMailComposeViewController.)