Note. This article uses Xcode 8 and Swift 3 .As iOS applications continue to grow in size, the
MVC pattern gradually loses its role as an “appropriate” architectural solution.
For iOS developers, more efficient architectural patterns are available, such as MVVM, VIPER, and
Riblets . They are very different, but they have a common goal: to break the code into blocks according to the principle of common responsibility with a multidirectional data stream. In a multidirectional stream, data moves in different directions between different modules.
')
Sometimes you don’t want (or you don’t need to) use a multidirectional data stream - instead you want the data to be sent in one direction: this is a unidirectional data stream. In this article about ReSwift, you will turn off the beaten path and learn how to use the
ReSwift framework to implement a unidirectional data flow when creating a
Memory Game application called
MemoryTunes .
But first - what is ReSwift?
Introduction to ReSwift
ReSwift is a small framework that helps you implement the
Redux architecture using Swift.
ReSwift has four main components:
- Views : Reacts to changes in the Store and displays them on the screen. Views sends Actions.
- Actions : Initiates a state change in the application. Actions are processed by the Reducer.
- Reducers : Directly changes the state of the application stored in the Store .
- Store : Stores the current state of the application. Other modules, such as Views , can connect and respond to changes.
ReSwift has a number of interesting advantages:
- Very strong limitations : it is tempting to place small code fragments in a convenient place, where in reality it should not be. ReSwift prevents this by placing very strong limits on what is happening and where it is happening.
- Unidirectional data flow : Applications that implement a multidirectional data flow can be very difficult to read and debug. One change can lead to a chain of events that send data across the entire application. Unidirectional flow is more predictable and significantly reduces the cognitive load required to read the code.
- Ease of testing : most of the business logic is contained in Reducers, which are pure functions.
- Platform Independence: All ReSwift elements — Stores, Reducers, and Actions — are platform independent. They can be easily reused for iOS, macOS or tvOS.
Multidirectional or unidirectional flow
To clarify what I mean when I talk about data flow, I’ll give the following example. An application created using VIPER supports multidirectional data flow between modules:
VIPER - Multidirectional Data StreamLet's compare it with unidirectional data flow in an application built on the basis of ReSwift:
ReSwift - unidirectional data flowSince data can only be transferred in one direction, it is much easier to visually follow the code and identify any problems in the application.
Getting started
Start by
downloading the project , which currently contains some source code and a set of frameworks, including ReSwift, which you will learn more about as you read this article.
First, you will need to configure work with ReSwift. Start by building the application kernel: its states.
Open
AppState.swift and create an AppState structure that matches the StateType:
import ReSwift struct AppState: StateType { }
This structure defines the state of the application.
Before creating the
Store , which will contain the value of AppState, you need to create a main
Reducer .
Reducer directly changes the current
AppState value stored in the
Store . Only Action can run a Reducer to change the current state of the application. Reducer generates the current
AppState value depending on the Action it receives.
Note. There is only one Store in the application and it has only one main Reducer.Create the main reducer function of the application in
AppReducer.swift import ReSwift func appReducer(action: Action, state: AppState?) -> AppState { return AppState() }
appReducer is a function that takes an Action and returns a modified AppState. The state parameter is the current state of the application. This function should change state accordingly, depending on the action received. Now just create a new AppState value - you will return to it as soon as you configure the Store.
It's time to create a Store that stores the state of the application, and the reducer, in turn, could change it.
Store stores the current state of the entire application: this is the value of your AppState structure. Open
AppDelegate.swift and replace the
import UIKit with the following:
import ReSwift var store = Store<AppState>(reducer: appReducer, state: nil)
This creates a global store variable initialized by the appReducer. appReducer is the main Reducer of the Store block, which contains instructions on how the store should change when an Action is received. Since this is the initial creation, not an iterative change, you are passing an empty state.
Compile and run the application to make sure we did everything right:
This is not very interesting ... But at least it works:]Interface Navigation
It's time to create the first real state of the application. You will begin by navigating the interface (routing).
Navigating to an application (or routing) is a challenge for every architecture, not just for ReSwift. You have to use a simple approach in MemoryTunes, where you define the entire list of screens in enum and the AppState will contain the current value. AppRouter will respond to changes in this value and show the current status on the screen.
Open
AppRouter.swift and replace
import UIKit with the following:
import ReSwift enum RoutingDestination: String { case menu = "MenuTableViewController" case categories = "CategoriesTableViewController" case game = "GameViewController" }
This
enum defines all the controllers presented in the application.
Now you have what to store in the State application. In this case, there is only one basic state structure (AppState), but you can divide the state of the application into sub-states specified in the main state.
Since this is a good practice, you will group state variables into sub-state structures. Open
RoutingState.swift and add the following sub-state structure for navigation:
import ReSwift struct RoutingState: StateType { var navigationState: RoutingDestination init(navigationState: RoutingDestination = .menu) { self.navigationState = navigationState } }
RoutingState contains
navigationState , which represents the current destination on the screen.
Note: menu is the default value for navigationState. This value is indirectly set by default when the application is started, unless you specify another one during RoutingState initialization.In
AppState.swift, add the following inside the structure:
let routingState: RoutingState
AppState now contains the RoutingState sub-state.
Run the application and you will see the problem:
The appReducer function is no longer compiled! This is due to the fact that you added routingState to the AppState, but did not pass anything to the default initializer call. To create a RoutingState you need a reducer.
There is only one main function in Reducer, but, like the state, reducers must be divided into sub-reducers.
Add the following Reducer to navigate to
RoutingReducer.swift :
import ReSwift func routingReducer(action: Action, state: RoutingState?) -> RoutingState { let state = state ?? RoutingState() return state }
Like the main Reducer, the routingReducer changes state depending on the action it receives, and then returns it. You have no actions yet, so a new RoutingState is created if state is nil and this value is returned.
Sub-reducers are responsible for initializing the initial values of the corresponding sub-states.
Return to
AppReducer.swift to correct the compiler warning. Modify the appReducer function to match this:
return AppState(routingState: routingReducer(action: action, state: state?.routingState))
We have added the routingState argument to the AppState initializer. action and state from the main reduser are passed to the routingReducer to determine the new state. Get used to this routine, because you have to repeat it for each sub-state and sub-reducer you create.
Subscribing / Subscription
Remember that the default menu value is set for RoutingState? In fact, this is the current state of the application! You just never subscribed to it.
Any class can subscribe to the Store, not just Views. When a class subscribes to the Store, it receives information about all changes that occur in the current state or sub-state. You need to do this in the AppRouter so that it can change the current screen for the UINavigationController when changing the routingState.
Open the
AppRouter.swift file and replace the AppRouter with the following:
final class AppRouter { let navigationController: UINavigationController init(window: UIWindow) { navigationController = UINavigationController() window.rootViewController = navigationController
In the above code, you updated the AppRouter class and added an extension. Let's take a closer look at what we did:
- AppState is now subscribed to the global store. In a closure expression, select indicates that you have subscribed to changes in routingState.
- pushViewController will be used to create an instance and add it to the navigation stack. It uses the instantiateViewController method, which loads the controller based on the passed identifier.
- Create an AppRouter that matches the StoreSubscriber so that newState gets callbacks as soon as the routingState changes.
- You do not want to activate the root view controller, so check if the current destination is the root.
- When the state changes, you add a new destination to the UINavigationController using rawValue for state.navigationState, which is the name of the view controller.
AppRouter will now respond to the initial menu value and display the MenuTableViewController.
Compile and run the application to make sure the error has disappeared:
At the moment, MenuTableViewController is displayed, which is empty. You will display a menu in it that will redirect the user to other screens.
View
Anything can be a StoreSubscriber, but most of the time it will be a view that responds to state changes. Your task is to make MenuTableViewControleller display two options of a submenu (or menu). It's time for the State / Reducer procedure!
Go to
MenuState.swift and create a state for the menu as follows:
import ReSwift struct MenuState: StateType { var menuTitles: [String] init() { menuTitles = ["New Game", "Choose Category"] } }
The MenuState structure consists of a menuTitles array, which you initialize with headings that will be displayed as a table.
In
MenuReducer.swift, create a Reducer for this state with the following code:
import ReSwift func menuReducer(action: Action, state: MenuState?) -> MenuState { return MenuState() }
Since MenuState is static, you do not need to worry about handling state changes. Thus, it simply returns the new MenuState.
Return to
AppState.swift . Add a MenuState to the end of the AppState.
let menuState: MenuState
It will not compile because you changed the default initializer again. In
AppReducer.swift, modify the
AppState initializer as follows:
return AppState( routingState: routingReducer(action: action, state: state?.routingState), menuState: menuReducer(action: action, state: state?.menuState))
Now that you have a MenuState, it's time to subscribe to it and use it when rendering the menu.
Open
MenuTableViewController.swift and replace the code with the following:
import ReSwift final class MenuTableViewController: UITableViewController {
The controller is now subscribed to the MenuState changes and declaratively displays the status on the user interface.
- TableDataSource is included in the launch system and acts as a declarative data source for the UITableView.
- Subscribe to the menuState in viewWillAppear. You will now receive callbacks in newState each time the menuState changes.
- If necessary, unsubscribe.
- This is the declarative part. Here you fill in the UITableView. You can clearly see in the code how the state is converted to a view.
Note. As you can see, ReSwift supports immutability - it actively uses structures (values), not objects. It also encourages you to create declarative user interface code. What for?
The newState callback, defined in StoreSubscriber, sends state changes. You may be tempted to fix the state value in a parameter, for example,
final class MenuTableViewController: UITableViewController { var currentMenuTitlesState: [String] ...
But writing declarative UI code, which clearly shows how a state is transformed into a view, is more comprehensible and much easier to use. The problem with this example is that UITableView does not have a declarative API. That's why I created the TableDataSource to eliminate the difference. If you are interested in the details, take a look at TableDataSource.swift .Compile and run the application and you will see the menu:
Actions / Actions
Now that you have a ready-made menu, it would be great if we could go over / open new screens with it. It's time to write your first Action.
Actions trigger a change in the Store. An action is a simple structure that can contain variables: Action parameters. Reducer processes the generated Action and changes the state of the application depending on the type of action and its parameters.
Create an action in
RoutingAction.swift :
import ReSwift struct RoutingAction: Action { let destination: RoutingDestination }
RoutingAction changes the current destination.
Now you are going to execute RoutingAction when you select a menu item.
Open
MenuTableViewController.swift and add the following to MenuTableViewController:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { var routeDestination: RoutingDestination = .categories switch(indexPath.row) { case 0: routeDestination = .game case 1: routeDestination = .categories default: break } store.dispatch(RoutingAction(destination: routeDestination)) }
This will set the routeDestination value based on the row you selected. Then dispatch is used to transfer the RoutingAction to the Store.
The action is ready to be executed, but is not supported by any reducer. Open
RoutingReducer.swift and replace the contents of the routingReducer with the following code, which updates the status:
var state = state ?? RoutingState() switch action { case let routingAction as RoutingAction: state.navigationState = routingAction.destination default: break } return state
switch checks if the action passed is an RoutingAction action. If so, use your destination to change the RoutingState, which is then returned.
Compile and run the application. Now, when you select a menu item, the corresponding view controller will appear on top of the menu controller.
Status update
You may have noticed an error in the current navigation implementation. When you click on the New Game menu item, the navigationState in RoutingState changes in the menu to game. But when you click the return button to return to the menu, the navigationState does not update anything!
In ReSwift, it is important to keep the state synchronized with the current state of the user interface. It's easy to forget about this when something is completely controlled by UIKit, for example, navigating a return or filling a text field by a user in a UITextField.
We can fix this if we update the navigationState when the MenuTableViewController appears.
In
MenuTableViewController.swift, add this line at the bottom of viewWillAppear:
store.dispatch(RoutingAction(destination: .menu))
If the user pushes the user’s button, this code will update the store.
Run the application and check the navigation again. Iiiii ... now navigation is completely faulty. Nothing is displayed.
Open
AppRouter.swift . Remember that pushViewController is called every time a new navigationState is received. This means that you are updating the RoutingDestination menu by clicking on it again!
You must perform an additional check if the MenuViewController is not displayed. Replace the contents of pushViewController with:
let viewController = instantiateViewController(identifier: identifier) let newViewControllerType = type(of: viewController) if let currentVc = navigationController.topViewController { let currentViewControllerType = type(of: currentVc) if currentViewControllerType == newViewControllerType { return } } navigationController.pushViewController(viewController, animated: animated)
You call the
type function
(of :) for the last view controller and compare it with the new one that appears when you click. If they match, then return two values.
Compile and run the application and the navigation should work fine if the menu is properly configured when you change the stack.
State updates using the user interface and dynamic checking of the current state are usually complex. This is one of the problems that you have to overcome when working with ReSwift. Fortunately, this should not happen very often.
Categories
Now you take a step forward and implement a more complex screen: CategoriesTableViewController. You must allow users to select a category of music so that they can enjoy playing in Memory while listening to their favorite artists. Start by adding states to
CategoriesState.swift :
import ReSwift enum Category: String { case pop = "Pop" case electronic = "Electronic" case rock = "Rock" case metal = "Metal" case rap = "Rap" } struct CategoriesState: StateType { let categories: [Category] var currentCategorySelected: Category init(currentCategory: Category) { categories = [ .pop, .electronic, .rock, .metal, .rap] currentCategorySelected = currentCategory } }
enum defines several categories of music. CategoriesState contains an array of available categories, as well as currentCategorySelected for status tracking.
In
ChangeCategoryAction.swift, add the following:
import ReSwift struct ChangeCategoryAction: Action { let categoryIndex: Int }
This triggers an action that CategoryState can modify using categoryIndex to refer to music categories.
Now you need to implement a Reducer that accepts the ChangeCategoryAction and saves the updated state. Open CategoriesReducer.swift and add the following:
import ReSwift private struct CategoriesReducerConstants { static let userDefaultsCategoryKey = "currentCategoryKey" } private typealias C = CategoriesReducerConstants func categoriesReducer(action: Action, state: CategoriesState?) -> CategoriesState { var currentCategory: Category = .pop
As in the case of other reducers, a method of full state update through actions is formed. In this case, you also save the selected category in
UserDefaults . For more details on how this happens:
- The current category is loaded from UserDefaults if it is available and used to create a CategoriesState image if it has not yet been created.
- Responding to a ChangeCategoryAction by updating the state and saving a new category in UserDefaults.
- getCurrentCategoryStateFromUserDefaults is a helper function that loads a category from UserDefaults.
- saveCurrentCategoryStateToUserDefaults is a helper function that saves a category in UserDefaults.
Helper functions are also pure global functions. You can put them in a class or structure, but they must always remain clean.
Naturally, you need to update the AppState with the new state. Open
AppState.swift and add the following to the end of the structure:
let categoriesState: CategoriesState
categoryState is now part of AppState. You have already mastered it!
Open
AppReducer.swift and change the return value to match this:
return AppState( routingState: routingReducer(action: action, state: state?.routingState), menuState: menuReducer(action: action, state: state?.menuState), categoriesState: categoriesReducer(action:action, state: state?.categoriesState))
Here you added a categoryState to the appReducer, passing action and categoriesState.
Now you need to create a categories screen, similar to MenuTableViewController. You will sign it to the Store and use a TableDataSource.
Open
CategoriesTableViewController.swift and replace the contents with the following:
import ReSwift final class CategoriesTableViewController: UITableViewController { var tableDataSource: TableDataSource<UITableViewCell, Category>? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated)
This is quite similar to MenuTableViewController. Here are some highlights:
- In viewWillAppear, subscribe to the categoriesState changes and post in viewWillDisappear.
- Called ChangeCategoryAction when the user selects a cell.
- In newState, tick a box for the currently selected category.
Everything is set up. Now you can choose a category. Compile and run the application and select the Choose Category to verify that it works correctly.
Asynchronous tasks
Is
asynchronous programming a difficult task? Yes! But not for ReSwift.
You get images for the Memory card from the iTunes API. But first you need to create a game state, a reducer and an action related to it.
Open GameState.swift, and you will see a MemoryCard structure representing the game card. It includes imageUrl, which will be displayed on the map. isFlipped indicates whether the face of the card is visible, and isAlreadyGuessed indicates whether the card has been guessed.
You will add game status to this file. Start by importing ReSwift at the top of the file:
import ReSwift
Now add the following code to the end of the file:
struct GameState: StateType { var memoryCards: [MemoryCard]
This determines the state of the game. In addition to the contents of the array of available memoryCards, specify the parameters:- Load indicator, visible or not.
- Game over.
Add a game Reducer to GameReducer.swift : import ReSwift func gameReducer(action: Action, state: GameState?) -> GameState { let state = state ?? GameState(memoryCards: [], showLoading: false, gameFinished: false) return state }
At the moment, just creating a new GameState. You will come back to this later.In the AppState.swift file , add a gameState to the end of the AppState: let gameState: GameState
In AppReducer.swift , update the initializer for the last time: return AppState( routingState: routingReducer(action: action, state: state?.routingState), menuState: menuReducer(action: action, state: state?.menuState), categoriesState: categoriesReducer(action:action, state: state?.categoriesState), gameState: gameReducer(action: action, state: state?.gameState))
Note. , Action / Reducer / State. , ReSwift , . Reducers Store Actions . , .,
SetCardsAction.swift :
import ReSwift struct SetCardsAction: Action { let cardImageUrls: [String] }
Action URL GameState.
.
FetchTunesAction.swift :
import ReSwift func fetchTunes(state: AppState, store: Store<AppState>) -> FetchTunesAction { iTunesAPI.searchFor(category: state.categoriesState.currentCategorySelected.rawValue) { imageUrls in store.dispatch(SetCardsAction(cardImageUrls: imageUrls)) } return FetchTunesAction() } struct FetchTunesAction: Action { }
fetchTunes iTunesAPI ( ). SetCardsAction . ReSwift : , . That's all.
fetchTunes FetchTunesAction, .
GameReducer.swift . gameReducer :
var state = state ?? GameState(memoryCards: [], showLoading: false, gameFinished: false) switch(action) {
state , , :
- FetchTunesAction showLoading true.
- In SetCardsAction, cards are randomly selected and showLoading is set to false. generateNewCards can be found in the file MemoryGameLogic.swift - the file that is included in the starting project.
It's time to lay out the cards in GameViewController. Start by creating a cell.Open the CardCollectionViewCell.swift file and add the following method to the end of the CardCollectionViewCell: func configureCell(with cardState: MemoryCard) { let url = URL(string: cardState.imageUrl)
The configureCell method performs the following actions:- Uses the excellent Kingfisher library for caching images.
- Shows the map if it is guessed or flipped.
. , ,
UICollectionView CollectionDataSource , .
GameViewController.swift UIKit :
import ReSwift
GameViewController showGameFinishedAlert:
var collectionDataSource: CollectionDataSource<CardCollectionViewCell, MemoryCard>? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) store.subscribe(self) { $0.select { $0.gameState } } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) store.unsubscribe(self) } override func viewDidLoad() {
, , StoreSubscriber. gameState viewWillAppear viewWillDisappear. viewDidLoad :
- fetchTunes iTunes API.
- Cells are configured using the CollectionDataSource, which gets the appropriate model for configureCell.
Now you need to add an extension to execute StoreSubscriber. Add the following to the bottom of the file:
This activates newState to handle state changes. The data source is updated as well:- The status of the load indicator is updated depending on the state.
- Restart the game and display a warning when the game is over.
Compile and run the game, select New Game and now you can see the maps.Play
. , , , . — .
.
FlipCardAction.swift :
import ReSwift struct FlipCardAction: Action { let cardIndexToFlip: Int }
FlipCardAction cardIndexToFlip GameState .
gameReducer FlipCardAction .
GameReducer.swift default:
case let flipCardAction as FlipCardAction: state.memoryCards = flipCard(index: flipCardAction.cardIndexToFlip, memoryCards: state.memoryCards) state.gameFinished = hasFinishedGame(cards: state.memoryCards)
FlipCardAction, flipCard Memory cardIndexToFlip . hasFinishedGame , , .
MemoryGameLogic.swift .
— . .
GameViewController.swift UICollectionViewDelegate. collectionView (_:didSelectItemAt:):
store.dispatch(FlipCardAction(cardIndexToFlip: indexPath.row))
indexPath.row FlipCardAction.
Run the game. Now you can play. ! :]
What's next?
You can download the final version of the MemoryTunes application here .There is still much to learn about ReSwift.- Software : There is currently no good way to handle Cross CuttingConcern in Swift. In ReSwift, you get it for free! You can solve various tasks using the Middleware function available in ReSwift. This makes it easy to handle logging, statistics, and caching.
- Navigation interface . You have implemented your own navigation for the MemoryTunes application. You can use a more general solution, such as ReSwift-Router . This is still an open problem and maybe you will be one of those who will solve it ?:]
- : ReSwift, , . Reducers , . , .
- : , ReSwift , . , .
- Persistence . , . — . ReSwift .
- : Redux : . ReSwift , Katana ReduxKit .
,
ReSwift talk — (Benjamin Encz), ReSwift
ReSwift's . , ,
Christian Tietze's blog ReSwift.
- , , !