Complete Guide to Singleton Pattern in Swift
Sep 18, 2024Just like how a country has only one President, Singleton as the name suggests, is the one that has only a single instance. It's one of the creational design patterns defined by "Gang of Four" in the book "Design Patterns".
This will be the last article you'll read on Singleton pattern as it covers all the aspects and nuances of Singleton pattern in Swift.
Table of Contents
- What is Singleton Design Pattern in Swift
- Why Singleton Pattern in Swift
- How to implement Singleton Pattern in Swift
- Are Singletons Thread Safe
- How to make Singletons Thread Safe
- Is Singleton an Anti-Pattern in Swift
- Implement Singleton with Dependency Injection in Swift
What is Singleton Design Pattern in Swift
Singleton design pattern guarantees that a class has only one instance throughout the application's lifetime and provides a global point of access to that instance, ensuring that all functionality is centralized and consistently available.
In Swift, Singleton classes have a private initialiser and shared instance of static type. It ensures lazy initialisation of class instance and is created only when it's used for the first time in any module of the application.
Why Singleton Pattern in Swift
If you are new to programming, you must find it difficult to comprehend why and when Singleton pattern should be used. This pattern is frequently used when you need a single instance of an oject and want to have global access to it across all the modules of the project.
For example : NetworkManager class, Crashlytics class, Analytics class, DatabaseManager class etc.
Unfortunately, some developers use Singletons only when they need a global access of the object across the app, but that's not a justifiable reason to implement this pattern because it comes with some overhead as well that will be discussed in the later half of this article.
Enough of theory, now, let's see how to implement Singleton pattern in a real project.
How to implement Singleton Pattern in Swift
Let's take a simple example, where you want to implement a Logger
class in your Xcode project.
The objective of Logger class is to log all the events across the application. For example, when user opens a view, when user dismiss a view, when user taps a button etc. Since this class will be required across the application, so in this case Singleton design pattern will be the most appropriate.
final class Logger {
static let sharedInstance = Logger()
private var logs: [String: String] = [:]
private init() {}
func log(key: String, message: String) {
logs[key] = message
print("Log added: \(key) -> \(message)")
}
func fetchLog(for key: String) -> String? {
return logs[key]
}
}
In the above code,
- We have a
final
classLogger
with a static let objectsharedInstance
that initialise the classLogger
only once. - The
sharedInstance
will be used across the application to access the instance of this class. - To avoid multiple initialisation, we've added the
private
access modifier to theinit()
method of the class. - The method
log(key: String, message: String)
logs the events information to the Database. - The method
fetchLog(for key: String)
fetches the value based on the key of an event.
Now, let's see how to use this Singleton Logger
to log views's .onAppear()
event in one of the SwiftUI view.
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
.onAppear() {
Logger.sharedInstance.log(key: "content-view",message: "onAppear called on ContentView")
}
}
}
In the above code,
- The
ContentView
has a.onAppear()
method that gets called when the view has appeared. - We've used the
Logger
singleton class to log this appear event by using thesharedInstance
and callinglog(key: String, message: String)
method over the instance.
This was a very basic implementation of Singletons. Now, what if you have to deal with concurrent tasks while using your Singleton class? Will the Singleton class and it's variable remain thread safe or will it have performance issues.
Let's find out by understanding the thread safety of Singletons.
Are Singletons Thread Safe
Some data structures in Swift don't guarantee thread safety. It means, if concurrent operations are performed over an object then the eventual state of the object will be undefined. This scenario is called data race, where multiple threads try to read and write over the same object or it's properties at the same time.
Using static let to initialise the Singleton ensures thread safety but are Singleton variable thread safe? Let's take the same Singleton class from the earlier example and try to observe if the variables of Singleton class are following thread safe practices or not.
final class Logger {
static let sharedInstance = Logger()
// Shared resource prone to race conditions
private var logs: [String: String] = [:]
private init() {}
func log(key: String, message: String) {
logs[key] = message // Potential data race when accessed concurrently
print("Log added: \(key) -> \(message)")
}
func fetchLog(for key: String) -> String? {
return logs[key]
}
}
Now, let's create a multi-threaded environment to run this Singleton through the test of thread safety. By default, DispatchQueue is serial. To make it concurrent, you will have to explicitly assign the .concurrent
attribute.
// Simulating concurrent logging in a multithreaded environment
let logger = Logger.sharedInstance
let queue = DispatchQueue(label: "com.logger.queue", attributes: .concurrent)
// Dispatch multiple concurrent writes to the shared logger
for i in 1...100 {
queue.async {
logger.log(key: "Task \(i)", message: "This is log message \(i)")
}
}
In the above code snippet, the log(key: String, message: String)
method of the Singleton class is looped in a multi-threaded asynchronous environment. This means, multiple threads will perform write operation over log dictionary concurrently that will lead to data race condition.
This proves, Singletons are not thread-safe by default but they can be made thread safe by following synchronisation practices using GCD(Grand Central Dispatch). Now, let's understand how to make Singletons thread-safe.
How to make Singletons Thread Safe in Swift
To solve the data race condition and make singletons thread safe, you can use synchronisation techniques like Serial Queue, Semaphore or Locking mechanism.
Using Serial Queue
Let's first try to solve the problem using Serial Queue mechanism and for that you'll have to create a serial DispatchQueue.
final class Logger {
static let sharedInstance = Logger()
private var logs: [String: String] = [:]
private let logQueue = DispatchQueue(label: "com.logger.queue.serial") // Serial queue to synchronize access
private init() {}
func log(key: String, message: String) {
logQueue.sync {
logs[key] = message // Access the shared resource in a synchronized manner
print("Log added: \(key) -> \(message)")
}
}
func fetchLog(for key: String) -> String? {
logQueue.sync {
return logs[key]
}
}
}
Here, the logQueue.sync
block ensures that task is performed over serial queue and only one thread can access the logs dictionary at a time, preventing it from race condition.
Note : Implementing synchronous blocks adds performance overhead to the program therefore, it should be used only where there's high probability of data race conditions.
Using DispatchSemaphore
Semaphores limit the number of threads executed over the shared resource at a given time. It works on the concept of critical section, it's the execution block between the semaphore.wait()
and sempahore.signal()
.
final class Logger {
static let sharedInstance = Logger()
private var logs: [String: String] = [:]
private let semaphore = DispatchSemaphore(value: 1) // Semaphore with value 1 to allow one thread at a time
private init() {}
func log(key: String, message: String) {
semaphore.wait() // Wait for the semaphore (block if another thread is in the critical section)
// Critical section: only one thread can access this at a time
logs[key] = message
print("Log added: \(key) -> \(message)")
semaphore.signal() // Signal to release the semaphore (allow other threads to enter)
}
func fetchLog(for key: String) -> String? {
semaphore.wait() // Wait for the semaphore (block if another thread is in the critical section)
return logs[key]
semaphore.signal() // Signal to release the semaphore (allow other threads to enter)
}
}
The semaphore.wait()
blocks other calling threads to enter the critical section if the maximum number(i.e. 1 in this case) of threads are already inside the critical section. The semaphore.signal()
is called when the critical section execution completes, it signals the other waiting threads to enter the critical section.
This way, only one thread is permitted to enter the critical section and prevents the log dictionary from data race condition.
Is Singleton an Anti-Pattern in Swift
There's a long debate over, if Singleton pattern is an Anti-Pattern in Swift? The fact of the matter is, Singletons are poor at state management and have low testability. Since, singleton object acts as a global variable, it can be accessed from anywhere in the project that makes it hard to maintain the record of when and which view made the change to it.
However, this problem can be solved by using Dependency Injection with Singletons. The idea is to not use Singleton as a global object across the project but make a wrapper class that can be injected in SwiftUI views and it's lifecycle can be managed.
Let's take a look at how to implement Singleton with Dependency Injection in Swift.
Implement Singleton with Dependency Injection in Swift
Let's create a wrapper LoggerService
class on top of Logger
class that will conform to the ObservableObject
protocol.
import SwiftUI
class LoggerService: ObservableObject {
private let logger = Logger.sharedInstance // Access the Logger singleton
// Method to log messages through Logger singleton
func log(key: String, message: String) {
logger.log(key: key, message: message)
}
// Method to fetch logs through Logger singleton
func fetchLog(for key: String) -> String? {
return logger.fetchLog(for: key)
}
}
You can observe that the logger class instance has been made private because we want to restrict direct access to the Singleton instance from any view.
Now, let's inject the LoggerService
class in a SwiftUI view. There are two ways how you can inject the service class in the view:
-
Make
@StateObject
of service class and pass it around the views as a dependency injection. -
Make an
@EnvironmentObject
of service class and inject it across the view heirarchy.
Let's implement the first approach,
import SwiftUI
struct ContentView: View {
// Inject LoggerService using @StateObject so that it persists within the view
@StateObject private var loggerService = LoggerService()
var body: some View {
VStack {
// Button to log a message
Button("Log Message") {
loggerService.log(key: "event1", message: "Button clicked at \(Date())")
}
// Fetch and display the last log if available
if let logMessage = loggerService.fetchLog(for: "event1") {
Text("Last Log: \(logMessage)")
.padding()
} else {
Text("No logs available")
.padding()
}
}
.padding()
}
}
The LoggerService
class adds an extra layer of implementation which helps in keeping the singleton class directly non-accessible across the project modules. The View will never hold the instance of Singletons in this case. The service class calls it's methods and eventually they call the methods written inside the Logger
class.
In conclusion, while using dependency injection with singletons may not be the most convenient approach, it effectively addresses the major limitations of singletons and gives you the ability to isolate them and make your codebase testable.
Where to go next?
Congratulations, you have mastered one of the most frequently used creational design pattern in iOS development. Learning more design patterns will help you build robust design for your iOS applications and make better decision while planning a new project. Next up, we recommend you to read Coordinator Pattern in SwiftUI and MVVM in SwiftUI.