Targets, Build Types, Flavors and Schemes
How Flutter works with different targets, build types, flavors and schemes

Flutter tries to unite everything under one roof, so you do not need to think about different platforms, build types, flavors and schemes.
However, the way Android and iOS work under the hood is different, and it can get confusing why you need to do certain things on every platform.
This is not a full-blown guide to all the concepts, but my view on the concepts and how they work together. Mainly, what to look for when you solve certain categories of problems.
What are they?
For those, who do not know, here is a quick overview of the concepts:
-
Targets (Apple): Defines a product to build. A target controls build settings and “Build Phases” (which files to compile), and together with its resources and configuration it produces things like the app binary, Bundle ID, and App Icon. Use multiple targets when you need two distinct apps or an app and a Widget.
-
Schemes (Apple): Defines how Xcode builds and runs one or more targets. It maps an action (Run, Test, Profile, or Archive) to a specific Build Configuration, such as Debug or Release. Use multiple schemes to switch quickly between environments like Staging and Production.
-
Build Types (Android): Defines how to build the app, such as
debugandrelease, and you can also add custom ones likestaging. Use different build types to enable or disable certain features, like logging, analytics, crash reporting and so on. -
Flavors (Android): Defines different versions of the app, like free and paid versions. Flavors can have different resources, configurations, and dependencies. When the flavor and build type are combined, you can define Source Sets, which are folders with code and resources that are only included in the specific flavor and build type.
Why am I writing about this?
I am working with apps, which have different flavors and schemes for many years now, but only now I have thought about writing about it.
From this point, if I write scheme or flavor, I will be talking about both platforms, because they are similar concepts, just with different names.
If you plan on delivering your app with different flavors, you have this handy tutorial from flutter how to handle it: Flutter Flavors. You can cover the Android and iOS and after tweaking the projects, you are done and from the most part you do not need to care.
The issue arises, when suddenly you need to add some specific native code.
In Android, you can do it quite easily with source sets. You will create new folders, write implementations for the specific flavor and build type, and you are done.
In iOS, I was always struggling. Feel free to call it a skill issue or lack of knowledge, but there was no clear way for me. I was encountering leftover code or some duplicate symbols as libraries were included in the wrong targets, and I was not sure how to solve it.
In the end, I have found out a couple of ways how to deal with it, but I have never found a clear guide on how to do it, and what are the best practices.
You want to add some native code for specific flavor/scheme?
In Android, just look up how to use source sets and you are done.
In iOS, you have a couple of options:
- Per Scheme / Build Configuration, you can define Swift compilation conditions and then use
#ifchecks in code. In practice, the scheme usually selects the build configuration that provides those flags. It works for small pieces of code, but it can get messy if you have a lot of code that is different between environments.
func configureFeatures() {
#if PRO_VERSION
print("Enable 4K Video Export")
#else
print("Show banner ads")
#endif
}
- Per Target, you can add different files to different targets. You can have a file with the same name in both targets, but with different implementations. It is a bit more work to set up, but it is cleaner and more maintainable in the long run.
// EnvironmentConfig_Dev.swift -> Checked only for Dev Target.
// EnvironmentConfig_Prod.swift -> Checked only for Prod Target.
// In EnvironmentConfig_Dev.swift
struct Environment {
static let apiBaseURL = "https://staging-api.myapp.com"
static let logLevel: LogLevel = .verbose
}
// In EnvironmentConfig_Prod.swift
struct Environment {
static let apiBaseURL = "https://api.myapp.com"
static let logLevel: LogLevel = .error
}
// In your shared ViewController.swift
// Both targets can call this because 'Environment' exists in both.
let url = Environment.apiBaseURL
- You can also use the new Swift Package Manager to create separate packages for different schemes, and include them in the targets. It is a bit more work to set up, but it is cleaner and more maintainable in the long run.
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "CoreModule",
products: [
.library(name: "CoreModule", targets: ["CoreModule"]),
],
dependencies: [
...
],
targets: [
.target(
name: "CoreModule",
dependencies: [
// This package will only be included in iOS platform
.product(name: "Package 1", package: "Package-1", condition: .when(platforms: [.iOS])),
// This package will only be included in Debug configuration
.product(name: "Package 2", package: "Package-2", condition: .when(configuration: .debug))
]
),
]
)
- When using Pods, you sometimes need to filter the pods per target. The main danger is not that a pod exists in multiple schemes, but that incompatible static dependencies or the same symbols get linked twice into the same final binary, which leads to duplicate symbol errors.
# Shared pods used by everyone
def shared_pods
pod 'package-1'
pod 'package-2'
end
target 'MyApp-Free' do
shared_pods
pod 'package-ads' # Only for Free
end
target 'MyApp-Pro' do
shared_pods
end
The unfortunate part is, when you start digging into the packages, you can lose the default Flutter plugin registration flow and need to register plugins manually, which can be a bit of a pain, but it is doable. You can use the generated plugin registrant code in the iOS project as a reference and register the needed plugins manually in the AppDelegate.
When you want to remove package
This is actually the core reason for writing this small article.
I was creating one app, which had 3 packages using LLMs under the hood.
Cactus, MediaPipe and Executorch. (More on why, in the near future).
Unfortunately, two of them were giving duplicated symbols, but I really wanted to use both of them. It would require a lot of work to separate them, so I decided in the end to create two targets.
ExecuTorch and MediaPipe were in one target using similar dependencies and that is why they were giving duplicated symbols.
Since, we are in Flutter, I had to create manual registration of the plugins instead of automatic, which was tedious.
import Flutter
import UIKit
@main
@objc class AppDelegateExecuTorch: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
let registry = engineBridge.pluginRegistry
registerPlugin(
pluginKey: "FPPBatteryPlusPlugin",
className: "FPPBatteryPlusPlugin",
moduleName: "battery_plus",
with: registry
)
...
}
}
private func registerPlugin(
pluginKey: String,
className: String,
moduleName: String,
with registry: FlutterPluginRegistry
) {
let candidateNames = [
className,
"\(moduleName).\(className)",
]
guard let pluginClass = candidateNames
.compactMap({ NSClassFromString($0) as? FlutterPlugin.Type })
.first else {
assertionFailure("Missing Flutter plugin class \(className) (\(moduleName))")
return
}
guard let registrar = registry.registrar(forPlugin: pluginKey) else {
assertionFailure("Missing Flutter registrar for \(pluginKey)")
return
}
pluginClass.register(with: registrar)
}
The project is still based in CocoaPods, so I had to filter the pods per target. which can get highly codebase specific, but the main idea is to have a function with shared pods, and then add specific pods to the targets.
def install_flutter_pods(excluded_plugins = [])
# 1. Get the full list of Flutter plugins from the project
all_plugins = flutter_get_plugin_list()
all_plugins.each do |plugin|
# 2. Check if we should skip this plugin for the current target
next if excluded_plugins.include?(plugin.name)
# 3. Handle modern Swift Package Manager (SPM) check
# Skip if the plugin is already being handled by SPM
next if plugin.has_swift_package?
# 4. Tell CocoaPods to include this plugin as a dependency
pod plugin.name, path: plugin.ios_path
end
end
Flutter is already migrating to the Swift Package Manager, but the support is still evolving and CocoaPods is still used as a fallback for plugins that do not support Swift Package Manager yet.
Afterwards, I had to link the binaries to the correct targets and link the proper implementations to proper targets. At the beginning it was a bit of mess, but after clean up and creation of proper schemes to run two different targets out of my VS Code, it was working quite well.
Conclusion
Hopefully, this will help someone to navigate through the dependency hell of iOS and Android when you have different flavors and schemes.
It is annoying, but when you know what to look for, you will definitely manage it.
Keep in mind, that it is not a Flutter issue, but an issue of the underlying platforms, so you need to understand how they work to be able to solve it.
Socials
Thanks for reading this article!
For more content like this, follow me here or on X or LinkedIn.