Best practice to change settings for each environment in iOS app (Swift)

Introduction

This article is the 5th day article of Swift/Kotlin Lovers Association Advent Calendar 2020. I participated because it was vacant.

Introducing best practices for switching variable values ​​for each environment in iOS app development.

background

The app I'm developing requires three API connections (for development, staging, and release). We often see how to add Staging to Build Configurations (hereafter referred to as" build configurations ") and branch with #if.

EnvironmentVariables.swift


enum EnvironmentVariables {
#if DEBUG
    static let apiBaseUri = "https://example.com/debug/"
#elseif STAGING
    static let apiBaseUri = "https://example.com/staging/"
#elseif RELEASE
    static let apiBaseUri = "https://example.com/release/"
#endif

However, these days, Xcode's build system assumes two types, Debug and Release, and adding a build configuration seems to cause problems around the library.

If you are using XcodeGen, it is better to change the settings for each environment and generate a project, so I will show you how to do it.

What I want to convey the most

The most important thing to tell you in this article is __ let's not mess with the build configuration and otherwise change the settings for each environment __. The "other way" is that it's relatively easy to do with XcodeGen.

However, I don't really understand the specific problems caused by tweaking the build configuration, so I'd appreciate it if you could tell me: bow:

However, my perception is as follows, so I agree with changing the settings in a different way from the build configuration regardless of whether there is a problem.

< script async src = "https://platform.twitter.com/widgets.js" charset = "utf-8">

Prerequisites

--I'm using XcodeGen If you're not using it, the bonus may be helpful

environment

Implementation

Implement so that the settings can be changed for each environment.

Makefile creation (optional)

Export the value you want to use in the project as an environment variable, and prepare a command to generate the project with XcodeGen. Since the environment variables to be exported change for each environment, it is recommended to use make etc. to make it a task.

I am creating a Makefile like this:

Makefile


DEBUG_ENVIRONMENT := DEBUG
STAGING_ENVIRONMENT := STAGING
RELEASE_ENVIRONMENT := RELEASE

.PHONY: generate-xcodeproj-debug
generate-xcodeproj-debug: # Generate project with XcodeGen for debug
	$(MAKE) generate-xcodeproj ENVIRONMENT=${DEBUG_ENVIRONMENT}

.PHONY: generate-xcodeproj-staging
generate-xcodeproj-staging: # Generate project with XcodeGen for staging
	$(MAKE) generate-xcodeproj ENVIRONMENT=${STAGING_ENVIRONMENT}

.PHONY: generate-xcodeproj-release
generate-xcodeproj-release: # Generate project with XcodeGen for release
	$(MAKE) generate-xcodeproj ENVIRONMENT=${RELEASE_ENVIRONMENT}

.PHONY: generate-xcodeproj
generate-xcodeproj:
	mint run xcodegen xcodegen generate

This Makefile exports the following values ​​to the ENVIRONMENT environment variable.

environment value
debug DEBUG
Staging STAGING
release RELEASE

The name of the environment variable does not have to be ENVIRONMENT.

For example, you can pass the API connection destination directly as API_BASE_URI. However, in that case, if there are other settings that you want to change for each environment, you have to export the environment variables each time. I export only one value that determines the environment and change the API connection destination within the project.

2020/12/16 postscript For example, if you really don't want to include the development and staging environment settings in the binary at the time of release, you may want to export the settings directly.

Modify project.yml

Inject the exported environment variables into your project.

project.yml


targets:
  {Product target name}:
    # {Omission}
    settings:
      base:
+       ENVIRONMENT: ${ENVIRONMENT}

In XcodeGen, you can get environment variables with $ {environment variable name}. Here, we created a User-Defined setting with the name ENVIRONMENT and injected the environment variables we exported earlier.

If you implement so far and execute make generate-xcodeproj-release, the following User-Defined will be created. スクリーンショット_2020-12-15_17_09_31.jpg

As you can see, all have a value of RELEASE regardless of the build configuration. In other words, environment and build configuration are independent of each other, and you can do "debug build in release environment" and "release build in staging environment".

Added User-Defined setting to Info.plist

Add the User-Defined setting defined in project.yml to Info.plist. スクリーンショット 2020-12-15 17.18.29.png

Key Type Value
Any String $(User-Defined setting name)

The Key is arbitrary, but for the sake of clarity, a name close to the User-Defined setting name is preferable.

Added EnvironmentVariables.swift

By adding it to Info.plist, you can now callBundle.main.object (forInfoDictionaryKey: "key")to get the settings from the Swift file.

I want to centrally manage environment variables, so I put them together in one file.

EnvironmentVariables.swift


import Foundation

enum Environment: String {
    case debug = "DEBUG"
    case staging = "STAGING"
    case release = "RELEASE"
}

enum EnvironmentVariables {
    static var environment: Environment {
        guard let environmentString = Bundle.main.object(forInfoDictionaryKey: "Environment") as? String,
              let environment = Environment(rawValue: environmentString)
        else {
            fatalError("Fail to load `Environment` from `Info.plist`.")
        }
        return environment
    }

    static var apiBaseUri: String {
        switch environment {
        case .debug:
            return "https://example.com/debug/"
        case .staging:
            return "https://example.com/staging/"
        case .release:
            return "https://example.com/release/"
        }
    }
}

This completes the implementation.

If the number of settings you want to change for each environment increases in the future, you can implement it in the same way as apiBaseUri. No modifications are required except for EnvironmentVariables.swift.

By the way, I made EnvironmentVariables an enumeration without cases simply because I want a namespace.

How to use

The usage is as follows.

  1. Use make generate-xcodeproj- ○○ to export environment variables and generate a project.
  2. Call the setting value with Swift with EnvironmentVariables. ××

As an example, run make generate-xcodeproj-staging to call the settings in AppDelefate.swift.

$ make generate-xcodeproj-staging 
/Applications/Xcode.app/Contents/Developer/usr/bin/make generate-xcodeproj ENVIRONMENT=STAGING
mint run xcodegen xcodegen generate
⚙️  Generating plists...
⚙️  Generating project...
⚙️  Writing project...
Created project at /Users/{username}/{Omission}/{Project name}.xcodeproj

AppDelefate.swift


@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        print(EnvironmentVariables.apiBaseUri) // "https://example.com/staging/"

        if EnvironmentVariables.environment == .staging { // true
            print("Environment is staging.")
        }

        return true
    }
}

I was able to get the setting values ​​for each environment!

By not setting environment to private in EnvironmentVariables.swift, you can branch the process for each environment.

Evolution: Allows protocols to be bitten and mocked

With the above implementation, it is difficult to replace the values ​​during unit testing, so we will make it possible to mock the protocol by biting it. If it is an enumeration type, it cannot be instantiated without a case, so I am changing it to a structure.

I'm using a mock generation library called Mockolo, so I've added a /// @mockable comment to the protocol.

EnvironmentVariables.swift


import Foundation
+ 
+ /// @mockable
+ protocol EnvironmentVariablesProtocol {
+     var environment: Environment { get }
+     var apiBaseUri: String { get }
+ }

enum Environment: String {
    case debug = "DEBUG"
    case staging = "STAGING"
    case release = "RELEASE"
}

- enum EnvironmentVariables {
-     static var environment: Environment {
+ struct EnvironmentVariables: EnvironmentVariablesProtocol {
+     var environment: Environment {
        guard let environmentString = Bundle.main.object(forInfoDictionaryKey: "Environment") as? String,
              let environment = Environment(rawValue: environmentString)
        else {
            fatalError("Fail to load `Environment` from `Info.plist`.")
        }
        return environment
    }

-     static var apiBaseUri: String {
+     var apiBaseUri: String {
        switch environment {
        case .debug:
            return "https://example.com/debug/"
        case .staging:
            return "https://example.com/staging/"
        case .release:
            return "https://example.com/release/"
        }
    }
}

The example to call in AppDelefate.swift changes as follows.

AppDelefate.swift


@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

+         let environmentVariables: EnvironmentVariablesProtocol = EnvironmentVariables()
-         print(EnvironmentVariables.apiBaseUri) // "https://example.com/staging/"
+         print(environmentVariables.apiBaseUri) // "https://example.com/staging/"

-         if EnvironmentVariables.environment == .staging { // true
+         if environmentVariables.environment == .staging { // true
            print("Environment is staging.")
        }

        return true
    }
}

It takes more effort to instantiate, but I prefer to bite the protocol because it's testable.

As an example, DI apiBaseUri via the initializer.

ApiClient.swift


final class ApiClient {
    private let apiBaseUri: String
    
    init(environmentVariables: EnvironmentVariablesProtocol) {
        self.apiBaseUri = environmentVariables.apiBaseUri
    }
}

In this case, all the properties and methods of EnvironmentVariablesProtocol can be called in the initializer, so I think it is possible to DI only apiBaseUri.

If you want to use only environment, DI of Environment makes it easy to understand because you can not call extra properties and methods (in this case, apiBaseUri property).

Foo.swift


final class Foo {
    private let environment: Environment
    
    init(environment: Environment) {
        self.environment = environment
    }

    func foo() {
        if environment == .staging {
            //Specific processing in a staging environment
        }
    }
}

Bonus: Use BuildConfig.swift

By using BuildConfig.swift developed by @ 417_72ki, you can change the settings for each environment even in projects that do not use XcodeGen.

Please refer to the slide below for details. https://speakerdeck.com/417_72ki/management-of-environment-variables-with-yamls-ver-dot-2

in conclusion

With this, you can rest assured that you will receive a request to change the settings for each environment! If there is another good way, please let me know in the comments etc .: relaxed:

This is the article on the 5th day of Swift/Kotlin Lovers Association Advent Calendar 2020. The next day is also an article by @uhooi.

Reference link

Recommended Posts

Best practice to change settings for each environment in iOS app (Swift)
[Swift 5] Processing and precautions for transitioning to the iOS settings app
How to change app name in rails
How to add sound in the app (swift)
Introduction to kotlin for iOS developers ①-Environment construction
[Swift] Use UserDefaults to save data in the app
How to transition from the [Swift5] app to the iPhone settings screen
[Swift 5] Recognize ON/OFF of Switch in custom cell for each cell
Change the injection target for each environment with Spring Boot 2
How to change application.properties settings at boot time in Spring boot
Gradle settings memo (multi-project to all in one) for myself
Change the setting value for each environment with Digdag (RubyOnRails)
How to install Web application for each language in Nginx
Use docker-compose.yml which is different for each environment in Makefile
[Android] Change the app name and app icon for each Flavor
How to change BackgroundColor etc. of NavigationBar in Swift UI
I want to create a chat screen for the Swift chat app!
[Rails] How to change the page title of the browser for each page
MicroProfile Config operation verification in Azure Web App for Containers environment