Strategy Pattern in iOS
what is strategy pattern?
The Strategy Pattern is a behavioral design pattern that allows you to define a family of algorithms, encapsulate each one, and make them interchangeable. This pattern lets the algorithm vary independently from clients that use it. In simpler terms, the Strategy Pattern is like having a set of tools (algorithms) that you can choose from to perform a task, and you can switch between these tools as needed without changing the code that uses them.
The strategy Pattern is particularly useful when you have multiple ways to perform a task, and you want to choose the implementation at runtime. For example, you might have different algorithms for sorting data, encrypting data, or fetching data from a network. By using the Strategy Pattern, you can easily switch between these algorithms without changing the code that uses them.
Example
Let’s assume you have a Person
object with name
and dateOfBirth
properties, and you want to sort an array of Person
objects based on either their name
or date of birth
.
struct Person {
let name: String
let dateOfBirth: Date
}
//Sorting Strategy Interface
protocol SortingStrategy {
func sort(array: [Person]) -> [Person]
}
// Concrete Sorting Strategy implementation used for sorting by Name
class NameSortStrategy: SortingStrategy {
func sort(array: [Person]) -> [Person] {
return array.sorted { $0.name < $1.name }
}
}
// Concrete Sorting Strategy implementation used for sorting by DOB
class DateOfBirthSortStrategy: SortingStrategy {
func sort(array: [Person]) -> [Person] {
return array.sorted { $0.dateOfBirth < $1.dateOfBirth }
}
}
// This class uses the sorting strategy interface to perform sorting
class PersonSorter {
private var strategy: SortingStrategy
init(strategy: SortingStrategy) {
self.strategy = strategy
}
func sort(array: [Person]) -> [Person] {
return strategy.sort(array: array)
}
func setStrategy(strategy: SortingStrategy) {
self.strategy = strategy
}
}
let persons = [
Person(name: "abdul ahad", dateOfBirth: Date()),
Person(name: "fahad", dateOfBirth: Date().addingTimeInterval(60)),
Person(name: "usman", dateOfBirth: Date().addingTimeInterval(30))
]
// Sort by name
let personSorterOld = PersonSorter(strategy: NameSortStrategy())
let sortedByName = personSorter.sort(array: persons)
print(sortedByName)
// Output: Sorted by name
// Sort by date of birth
let personSorterNew = PersonSorter(strategy: DateOfBirthSortStrategy())
let sortedByDateOfBirth = dobSorter.sort(array: persons)
print(sortedByDateOfBirth)
// Output: Sorted by date of birth
// we can switch strategies during runtime using method injection
personSorterOld.setStrategy(strategy:DateOfBirthSortStrategy())
let sortedByDateOfBirthNew = personSorterOld.sort(array: persons)
print(sortedByDateOfBirthNew)
// Output: Sorted by date of birth
This code defines a Person
struct with name
and dateOfBirth
properties, and implements the Strategy Pattern to sort an array of Person
objects by either their name or date of birth. The SortingStrategy
protocol declares a sorting method, and two classes (NameSortStrategy
and DateOfBirthSortStrategy
) implement this protocol to provide specific sorting algorithms. The PersonSorter
class uses a SortingStrategy
to sort Person
objects, allowing the sorting strategy to be changed at runtime.
By applying the Strategy Pattern, you’ve created a flexible and extensible system for sorting Person
objects. You can easily add new sorting strategies without changing the existing code that uses the SortingStrategy
protocol. This approach promotes code reusability and makes your code more maintainable and easier to extend with new sorting algorithms in the future.
Real-World Example in iOS
let's look at a real-world example where we use the strategy pattern to load data either remotely or locally using async await
import Foundation
// Protocol defining the loader strategy
public protocol LoaderStrategy {
func load() async -> [String]
}
// Concrete strategy for loading data from a remote API
public class RemoteLoaderStrategy:LoaderStrategy{
public init(){}
public func load() async -> [String]{
//api
try? await Task.sleep(nanoseconds: 1_000_000_000)
let values = ["remoteData1","remoteData2"]
let date = Date().addingTimeInterval(60)
return values
}
}
// Concrete strategy for loading data from a local cache
public class LocalLoaderStrategy:LoaderStrategy{
public init(){}
public func load() async -> [String] {
//cache work
try? await Task.sleep(nanoseconds: 5)
let values = ["localData1","localData2"]
return values
}
}
// viewmodel that uses the strategy dependency but
// is agnositc of the provinence of data
public class StrategyViewModel{
let loader:LoaderStrategy
public var data = [String]()
public init(loader: LoaderStrategy) {
self.loader = loader
}
public func loadData() async {
let values = await loader.load()
self.data = values
}
}
LoaderStrategy
to define the interface for different loading strategies. It declares a single method load
that returns data asynchronously.
RemoteLoaderStrategy
conforms to the LoaderStrategy
protocol and represents a strategy for loading data from a remote API. It implements the load
method asynchronously, simulating data retrieval from a remote server.
LocalLoaderStrategy
similarly to RemoteLoaderStrategy
, conforms to the LoaderStrategy
protocol and represents a strategy for loading data from a local cache. It implements the load
method asynchronously, simulating data retrieval from a local cache.
StrategyViewModel
this class acts as a context for using the loader strategies. It holds a reference to an LoaderStrategy
instance. The data
property stores the loaded data. The loadData
method is responsible for initiating the data-loading process
This is how the diagram would look like StrategyViewModel
is agnostic of where the data comes from cause it is dependent on an interface called LoaderStrategy
which means we can replace it with any implementation in runtime so we can easily switch between the data sources. This also makes it testable cause we can also replace LoaderStrategy
with a test double.
Testing the viewModel
This class is designed to test the behavior of StrategyViewModel
under different loading strategies, specifically a remote loading strategy and a local loading strategy.
import Foundation
import DesignPatterns
import XCTest
class StrategyTests: XCTestCase {
func test_ViewModel_RemoteStrategy() async {
let (remoteStrategy,_) = makeSUT()
let viewModel = StrategyViewModel(loader: remoteStrategy)
await viewModel.loadData()
XCTAssertEqual(viewModel.data,["remoteData1", "remoteData2"])
}
func test_ViewModel_localStrategy() async {
let (_,localStrategy) = makeSUT()
let viewModel = StrategyViewModel(loader: localStrategy)
await viewModel.loadData()
XCTAssertEqual(viewModel.data,["localData1", "localData2"])
}
// MARK: - Helpers
private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> (LoaderStrategy,LocalLoaderStrategy){
let remoteStrategy = RemoteLoaderStrategy()
trackForMemoryLeaks(remoteStrategy,file: file,line: line)
let localStrategy = LocalLoaderStrategy()
trackForMemoryLeaks(localStrategy,file: file,line: line)
return (remoteStrategy,localStrategy)
}
}
Conclusion
Overall, the Strategy pattern offers invaluable advantages. It enables dynamic algorithm selection at runtime, allowing apps to quickly adapt to evolving requirements without extensive code modifications. Strategies encapsulate specific functionalities, fostering effortless reuse across different parts of an application, thus enhancing development efficiency and minimizing redundancy. By promoting clear separation between the Context and the Strategies, the pattern facilitates code comprehension and reduces the proliferation of complex conditional statements, ultimately improving code maintainability. Moreover, the Strategy pattern enhances testability by encapsulating behaviors, enabling more comprehensive and efficient testing processes, which leads to higher overall code quality and reliability in iOS applications.
Reference
Github: https://github.com/abdahad1996/DesignPatterns_Bootcamp