The most common alternative approach
A common solution to supporting multiple environments is to use Xcode Configurations and Schemes for the app design. This is the recommended approach from the top search results on the subject.
An Xcode Scheme is created for each environment (e.g. Development, Test, UAT, Staging, Production). Each Scheme then uses a different Build Configuration. Every single Build Setting can be configured differently for each Configuration - including Architectures; Base SDK; whether On Demand Resources are enabled; whether Bitcode is enabled; which Info.plist to use; which Provisioning Profile to use; or what the Bundle Identifier will be.
Any of these settings, and a whole host of others, can be changed per Configuration. In doing so, you run the risk of – at best – not being able to install the app on your device after archiving it and distributing an in-house build. At worst, you run the risk of releasing to the App Store with unwanted settings.
Also, if you have a legitimate reason to change a particular setting for all of your "release" environments, then you need to change it in multiple places.
One example of an unwanted setting reaching the App Store is the Supported Interface Orientations, defined in the Info.plist. The app could go through all levels of testing, with videos verified as working correctly in landscape. But then you come to install the version you released to the App Store and it does not support playing video in landscape. This is because a different Info.plist was used during testing.
So really, we want to ensure that the Xcode Build Settings we are testing against are the exact same settings that we release the app with. A clear advantage to this approach is that it is simple to change environment during development by changing the Build Scheme in Xcode.
Common safeguards still have their disadvantages
Developers would usually limit what they change per Configuration to a list of User-Defined Build Settings (although there is no way to enforce this restriction).
User-Defined Build Settings can be referenced from the Info.plist. This way you don't have to create a separate Info.plist for each environment, you would have a single default Info.plist from which you reference your custom settings. Plus you can define custom settings for each environment supported.
However, because Xcode does not defend against changing other Build Settings, you still risk encountering the issues described above.
Another disadvantage to User-Defined Build Settings is that they are all defined as strings, with no validation of what has been input. Also, because all of your settings are entered as strings, you need to convert them to the data type you're actually interested in, which might be a Boolean or a URL, and we need to write repetitive code to convert each setting.
So you might have a setting that contains the URL for the main entry point into your backend. If this is an invalid URL that cannot be parsed to create an NSURL instance, then you won't find out until you run the app.
Wouldn't it be nice to know about this error when compiling?
There are various other app design solutions to the multi-environment problem. One of which is to use GCC pre-processor macros. However, this is a messy approach which usually requires defining macros in a Configuration Settings File (a.k.a. .xcconfig) for development, and passing as command line arguments from a build script for release.
It's a little messy to set up, but can be done without the need for multiple Configurations. This means that you can do all of your development on the default "Debug" Configuration, and carry out all levels of testing against the "Release" Configuration, by injecting the pre-processor macro based settings at compile time.
A major disadvantage to this approach is that in development, we change environments by commenting out in our Configuration Settings File. However, this is not an ideal way to change configuration.
The solutions described above all require you to develop some kind of AppEnvironment class (or struct) to read the settings from the Info.plist or from another Plist or flat file, or to create settings from the pre-processor macros. And all implementations of such class that I've seen have contained other logic to convert from strings into various data types, including URLs. Ultimately, we want a solution that does not require this component to be implemented manually.
Every time you add a new setting, you need to add it to your property list or flat file, and manually edit the code of your (e.g.) AppEnvironment file to add a property to wrap/hide the access to the property list, such as: