It has been just over a year since we shipped our replatformed iOS app to great reviews and many happy customers. In this post I would like to take a look back at where we came from and how the FreeAgent mobile app is shaping up for the future.
We started developing the mobile app way back in 2014. At the time the entire engineering team was ~20 people and our engineers were mainly skilled in web development, so Cordova + React was a sensible platform to use. It allowed us to quickly get an app launched as well as keep it updated for both iOS and Android with a small team. For the data layer we used Backbone, which eventually we had to work around when we moved to a Flux-based app state.
In order to compile and ship the old mobile app we had a complex setup that stemmed from our initial MVP efforts: a Rails skeleton that would compile CoffeeScript and SASS assets and move them over to an output folder. A script would copy the assets to a path expected by Cordova and would then prepare the Cordova app for building. This process would often take 1 minute on average, only after which we could run or debug the app in Xcode. (To avoid this, we would usually run a Rails server and use the app in Chrome but using Xcode was sometimes necessary.)
As the years went by and the app grew in functionality – we added push notifications, Haptic Touch menu, file attachments among other features – issues started to appear with our tech stack:
- Cordova used UIWebView which was deprecated by Apple and did not contain the newer Nitro JavaScript engine that WKWebView benefited from. This resulted in slower app speed, especially on older devices such as iPhone 4S.
- WebKit would behave differently between iOS versions, sometimes leading to visual glitches that were not straightforward to fix across all versions.
- We were limited in our accessibility efforts, as Dynamic Text and UIAccessibility were not available in web views.
- Various keyboard issues arose, such as not being able to use more precise keyboard types and viewport resizing issues when the keyboard covered the screen.
- CodePush deployments would fail for a portion of the user base, leaving them on older code.
- Flow of data between JavaScript and native Swift code was awkward and had to go through native plugins. As time went by we found ourselves writing more and more native code.
- A good chunk of effort went into duplicating UI elements such as tabbed navigation or time pickers. These elements have 10 years of development at Apple behind them and duplicating them ourselves resulted in inferior solutions that did not look or behave exactly like the native UIKit controls.
Most importantly, we knew the user experience could be better. We pride ourselves in excellent user experience on FreeAgent’s desktop app but not the same could be said for our Cordova-based app. We also wanted to explore other areas, such as deeper Haptic Touch integration, swipe actions, richer notifications, widgets and more system integration than we could do using Cordova.
Starting the replatforming
At FreeAgent we use RFDs to decide on architecture and future direction of our code. In March 2018 the mobile team started a discussion about why replatforming to use 100% native code made sense. We had already made a few attempts at this during our popular Hack Days – including a sample Apple Watch companion app – so we had some idea of what replatforming would entail.
We set out a 1-year timeline in which we would keep the existing Cordova app in maintenance mode while gearing up for the native rewrite:
- 2-3 months for hiring and onboarding
- 2 months for banking (the most challenging area of the app)
- 1.5 months for invoices and estimates
- 1.5 months for bills and expenses
- 1 month for timeslips
- 1-1.5 months for beta testing and bug fixing
At the time, the mobile team consisted of just 2 people (Anup and I) and we knew we needed more in order to build and maintain the project, especially as the iOS and Android projects would be completely separate. Over time the team grew from 2 people to 10: 3 iOS engineers, 3 Android engineers, 1 designer, 1 test engineer, 1 engineering manager and 1 product manager.
Once we had the team in place it was time to start the rewrite. The first version was intended to match the functionality, look, and behaviour of the old Cordova app. This approach allowed us to target a fixed spec and have a solid foundation upon which to build new features after the initial release. It also helped us to onboard new developers more easily, as they had an existing app to recreate.
Native tech stack
Based on our experiences with the hybrid Cordova stack, we knew we wanted to write the new app in as much native code as possible. Swift was the obvious choice, along with Objective-C for any code we could copy over from the legacy app, such as the Face ID prompt.
The rest of the project is standard iOS development toolset:
- CocoaPods for dependency management
- Firebase for error reporting and analytics
- TestFlight for beta testing and feeding internal builds to the company
- Bitrise for continuous integration
What’s more interesting is the project organisation. We keep all our own code in a single Xcode project and a single Xcode target. The files are grouped by feature:
Technically, we split the app into 4 layers:
- API / Model: contains all the logic to communicate with our public API, including API clients and data models such as invoices or bank transactions
- View: view controllers, XIBs, Storyboards, collection view classes, etc
- View Model: classes that view controllers bind to in order to get the data to display
Coordinators: navigation between screens, allowing us to keep every view controller independent of others
API layer
The API layer is simple and consists of an API client using URLSession, as well as data models that match our API data structure. These models conform to Codable and are decoded using JSONDecoder built into Swift.
We aim to keep the API layer lower-level and independent of the rest of the app’s code; eventually we may move it into its own framework.
One interesting bit in the API layer is how our client looks:
protocol ApiClient: AnyObject { var delegate: ApiClientDelegate? { get set } var accessToken: String? { get set } func get<T: ApiResource>(_ endpoint: ApiEndpoint, as resourceType: T.Type, completion: @escaping (Result<T, Error>) -> Void) -> Request? func put<T: ApiResource>(_ resource: T, endpoint: ApiEndpoint, completion: @escaping (Result<T, Error>) -> Void) -> Request? func put<IT: ApiResource, OT: ApiResource>(_ resource: IT, endpoint: ApiEndpoint, completion: @escaping (Result<OT, Error>) -> Void) -> Request? func post<T: ApiResource>(_ resource: T, endpoint: ApiEndpoint, completion: @escaping (Result<T, Error>) -> Void) -> Request? func post<IT: ApiResource, OT: ApiResource>(_ resource: IT, endpoint: ApiEndpoint, completion: @escaping (Result<OT, Error>) -> Void) -> Request? func delete<T: ApiResource>(_ resource: T, endpoint: ApiEndpoint, completion: @escaping (Result<T, Error>) -> Void) -> Request? }
We have generic methods that map to the HTTP methods on the server. Each takes in an ApiResource (all our models conform to this) and an endpoint. The endpoints are specified in each model like this:
struct EmailTemplate: ApiResource { enum Endpoint: ApiHTTPEndpoint { case invoiceEmail(id: String) case creditNoteEmail(id: String) case estimateEmail(estimateId: String) var rootPath: String { switch self { case let .invoiceEmail(id): return "invoices/\(id)/email_template" case let .creditNoteEmail(id): return "\(CreditNote.Endpoint.creditNotesPath)/\(id)/email_template" case let .estimateEmail(id): return "estimates/\(id)/email_template" } } } }
The API client is called like this:
let templateEndpoint = EmailTemplate.Endpoint.invoiceEmail(id: "1") apiClient.get(templateEndpoint, as: EmailTemplate.self) { ... }
MVVM
Model-View-View-Model (MVVM for short) is a pattern created at Microsoft by Ken Cooper and Ted Peters. It allows greater separation between business logic and view-related logic. The basic idea is that we have a view controller which grabs data from and issues commands to the view model. The view controller is not bound to anything else and does not know any business logic details. The view model is the one bound to data sources, business logic, etc. It also transforms raw values into user-friendly ones for display.
There are various ways to use MVVM. The most useful way is by mixing in something like SwiftUI or XAML and doing data binding or using reactive frameworks to bind values to UI. However, as we did not require such complexity our application of MVVM is relatively simple:
- View models have properties, which the view controller reads, and methods to do work such as loading data from the API.
- View controllers have a viewModel property, which is injected by coordinators. They observe this object using a closure, which is called whenever something of interest happens in the view model.
- When a change happens in the view model, the view controllers update the UI to match.
Let’s take a real example from our email screen:
class EmailViewModel { enum Change { case loading(Bool) case updated case sent case failure(Error) } var didChange: ((Change) -> Void)? private let apiClient: ApiClient func send() { let emailWrapper = EmailWrapper(email: email, resource: resource) apiClient.post(emailWrapper, endpoint: sendEndpoint) { [weak self] result in switch result { case .success: self?.didChange?(.sent) case let .failure(error): self?.handleSendError(error) } } } func loadData() { didChange?(.loading(true)) apiClient.get(templateEndpoint, as: EmailTemplate.self) { [weak self] result in guard let self = self else { return } guard case let .success(emailTemplate) = result else { self.email.subject = self.defaultSubject self.didChange?(.loading(false)) self.didChange?(.updated) self.loadVerifiedEmails() return } self.email.to = emailTemplate.recipient ?? self.email.to self.email.subject = emailTemplate.subject self.email.body = emailTemplate.body.html self.loadVerifiedEmails() } } private func loadVerifiedEmails() { apiClient.get(VerifiedEmailAddresses.Endpoint.all, as: VerifiedEmailAddresses.self, completion: { result in guard case let .success(verifiedAddresses) = result else { return } let emails = verifiedAddresses.emails self.verifiedEmailAddresses = emails self.email.from = emails.first(where: { $0 == self.defaultSender.email }) ?? emails.first self.didChange?(.loading(false)) self.didChange?(.updated) }) } } class EmailViewController: UIViewController, AnalyticsScreenViewTracking { var viewModel: EmailViewModel! override func viewDidLoad() { super.viewDidLoad() // More UI setup here... viewModel.didChange = { [weak self] change in DispatchQueue.main.async { self?.viewModelChanged(change) } } viewModel.loadData() } func viewModelChanged(_ change: EmailViewModel.Change) { switch change { case let .loading(isLoading) where isLoading == true: emailEditorView.isEditable = false loadingActivityIndicator.startAnimating() case let .loading(isLoading) where isLoading == false: emailEditorView.isEditable = true loadingActivityIndicator.stopAnimating() case .updated: viewModelUpdated() case .sent: navigationItem.rightBarButtonItem?.isEnabled = true isFinishing = true delegate?.emailViewControllerDidSend(self) case let .failure(error): handleError(error) default: break } } }
The view models have a didChange property, which is called whenever something happens that requires an update to the UI. They use an injected apiClient to call the API and when the API responds the view model emits events.
The view controller has a method, which is then called, and switches between the various event types for its particular view model. Handling all events in a single method like this is something that Apple recommend in their sample code. We found it to be helpful in debugging issues as the code is localised at only one point in the view controller.
Coordinators
Usually navigation in iOS is done using storyboards and segues. These are quite inflexible as a project grows due to a few reasons:
- View controllers need to know about each other and what data the next one in the flow holds
- Navigation flow is fixed and is harder to dynamically switch based on conditions such as feature switches or A/B testing
- Up until iOS 13 it was not possible to intercept the segue to run custom initialisation code for a view controller
- Storyboards and segues cannot be easily unit tested
After looking at a few solutions for this, we settled on the coordinator pattern. The coordinators are simple classes that push view controllers onto the navigation stack. At the root of our app we have a “master” coordinator called AppCoordinator. This handles login / logout flow: handling universal link navigation, showing the Face ID prompt on resume and processing Haptic Touch actions from the home screen icon.
Further down we have coordinators for each section of our app: LoginCoordinator, OverviewCoordinator, BankingCoordinator, etc. Each of these has methods to set up and push the relevant view controllers onto the navigation stack. Navigation between views is made via delegation:
protocol EmailViewControllerDelegate: AnyObject { func emailViewControllerDidPop(_ viewController: EmailViewController, wasCancelled: Bool) func emailViewControllerDidSend(_ viewController: EmailViewController) func emailViewControllerNavigatesToSenderSelector(_ viewController: EmailViewController, selectedSender: String?, verifiedEmailAddresses: [String]) func emailViewControllerNavigatesBackFromUserSelector(_ viewController: EmailViewController) func emailViewControllerNavigatesToPreview(_ viewController: EmailViewController, resource: Emailable) } extension EmailInvoiceCoordinator: EmailViewControllerDelegate { // ... func emailViewControllerNavigatesToPreview(_ viewController: EmailViewController, resource: Emailable) { guard let invoicable = resource as? Invoicable, let previewEndpoint = PDFPreview.Endpoint.makeForInvoicable(invoicable) else { return } let previewVC = PDFPreviewViewController() previewVC.title = "\(type(of: resource).humanisedType.capitalized) Preview" previewVC.apiClient = apiClient previewVC.baseUrl = ClientCredentials.subdomainUrl(subdomain: companySubdomain) previewVC.previewEndpoint = previewEndpoint previewVC.delegate = self present(previewVC, over: viewController) } }
Implementing dynamic views
Our app has complex views that change depending on various conditions. For example, we show more options if mileage VAT is reclaimed on fuel. Bank transactions also have various types which correspond to different options on screen.
The easiest way to implement dynamic views is to use a collection view. However, we did not wish to bloat our view models or view controllers with a lot of boilerplate code to handle various conditions and states. We ended up extracting those concerns in what we call “cell providers”.
There are two main aspects to cell providers:
- The cell providers themselves, which register cell XIBs with the collection view and vend reusable cells to the view controller
- Cell model providers, which provide models bound to cell types to the view model
When a view appears on screen, the view controller asks the view model how many cells it should display, what sections it has etc.:
class InsightsViewController: UIViewController { var viewModel: InsightsViewModel! private var cellProvider: InsightsCellProvider! override func viewDidLoad() { cellProvider = InsightsCellProvider( collectionView: collectionView, columnDelegate: self ) // ... } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let panel = viewModel.cellModel(at: indexPath) else { fatalError("Could not find data for overview section at \(indexPath)") } return cellProvider.cellForItem(at: indexPath, for: panel, in: collectionView) } // ... }
The view model, in turn, asks its cell model provider what models it has and returns those to the view controller:
class InsightsViewModel { let cellModelProvider: InsightsOverviewCellModelProvider func updateSections(with homeInfo: HomeInfo) { self.homeInfo = homeInfo cellModelProvider.reloadData(viewModel: self) notifyChange(.updated) } // ... }
final class InsightsOverviewCellModelProvider: OverviewCellModelProviding, FreeTrialCellProviding, GenericNoticeCellProviding { private(set) var sections: [CollectionViewSectionModel] = [] func reloadData(viewModel: OverviewViewModel) { let genericNoticeSection = self.genericNoticeSection(notice: viewModel.homeInfo?.notice) let freeTrialSection = freeTrialNoticeSection( subscribeIdentifier: InsightsIdentifiers.subscribeNow, sessionSettings: viewModel.sessionSettings, permissionGuard: viewModel.permissionGuard ) let cashFlowSection = self.cashFlowSection(viewModel: viewModel) let profitAndLossSection = self.profitAndLossSection(viewModel: viewModel) let taxTimelineSection = self.taxTimelineSection(viewModel: viewModel) let notificationSections = genericNoticeSection + freeTrialSection let buttonSections = cashFlowSection + profitAndLossSection + taxTimelineSection sections = notificationSections + buttonSections } private func taxTimelineSection(viewModel: OverviewViewModel) -> [CollectionViewSectionModel] { guard viewModel.permissionGuard.canAccessTaxTimeline else { return [] } return [ CollectionViewSectionModel( cells: [ OverviewHeaderPanel( identifier: InsightsIdentifiers.taxTimeline, title: "Tax Timeline", detail: viewModel.sessionSettings.currentCompany.name, isEnabled: true, boldDetail: false ) ] ) ] } // ... }
The view controller passes the cell models onto the cell provider, which knows how to vend various cells depending on the models it receives:
class InsightsCellProvider { enum CellIdentifiers: String { case overviewHeaderCell case overviewLabelCell case overviewDebugCell case notificationCell case overviewBannerCell case overviewColumnsCell } private(set) var collectionView: UICollectionView func registerCells() { collectionView.register(NotificationCell.nib, forCellWithReuseIdentifier: CellIdentifiers.notificationCell.rawValue) collectionView.register(OverviewHeaderCollectionViewCell.nib, forCellWithReuseIdentifier: CellIdentifiers.overviewHeaderCell.rawValue) collectionView.register(OverviewLabelPanelCollectionViewCell.nib, forCellWithReuseIdentifier: CellIdentifiers.overviewLabelCell.rawValue) collectionView.register(OverviewBannerCollectionViewCell.nib, forCellWithReuseIdentifier: CellIdentifiers.overviewBannerCell.rawValue) collectionView.register(OverviewColumnsCell.nib, forCellWithReuseIdentifier: CellIdentifiers.overviewColumnsCell.rawValue) } func cellForItem(at indexPath: IndexPath, for cellModel: CollectionViewCellModel, in collectionView: UICollectionView) -> UICollectionViewCell { switch cellModel { case let cellModel as OverviewHeaderPanel: return headerCell(at: indexPath, withModel: cellModel, in: collectionView) case let cellModel as OverviewLabelPanel: return labelCell(at: indexPath, withModel: cellModel, in: collectionView) case let cellModel as NotificationViewModel: return notificationCell(at: indexPath, withModel: cellModel, in: collectionView) case let cellModel as OverviewBannerViewModel: return bannerCell(at: indexPath, withModel: cellModel, in: collectionView) case let cellModel as OverviewColumnsPanel: return columnsCell(at: indexPath, withModel: cellModel, in: collectionView) default: fatalError("\(type(of: cellModel)) does not have a counterpart cell") } } private func labelCell(at indexPath: IndexPath, withModel cellViewModel: OverviewLabelPanel, in collectionView: UICollectionView) -> UICollectionViewCell { let cell: OverviewLabelPanelCollectionViewCell = collectionView.dequeueReusableCell( withReuseIdentifier: CellIdentifiers.overviewLabelCell.rawValue, for: indexPath ) cell.setData(fromPanel: cellViewModel) return cell } // ... }
This approach allowed us to keep the view models and view controllers slim. It also allowed us to more easily mock and test relevant view logic depending on state.
Testing
Our testing approach involves a mix of automated and manual testing. Initially, our strategy was to build up UI tests to match user journeys in the app and build unit tests for mostly used features and covering edge cases. We quickly found out, however, that UI tests were really slow on CI. As a result, we shifted our strategy to cover as much of the app as we could with unit and system tests, alongside manual release candidate testing, before pushing updates to the App Store.
We use the standard XCTest framework without any extra libraries. Due to our heavy use of dependency injection, testing is quite easy, especially around API layers and view models. This gives us good confidence that changes we make to the app logic will not break existing functionality.
As we build up our automated tests, manual testing is increasingly reserved for hard-to-test areas such as Face ID prompt, Haptic Touch quick actions, attachment uploads etc.
Moving forward
We are really happy with how the native version of the iOS mobile app has turned out and it seems that our customers are as well. We’ve received a lot of feedback that the app is more responsive and stable since the rewrite, and our App Store rating has grown to a stunning 4.9 stars. 98% of sessions are crash-free. Accessibility is much improved and we’ll continue to make strides in this area, as making our products accessible is an important goal for all of us at FreeAgent.
On the engineering side, we are now much more agile and have fewer tech stack issues to worry about. Deploying updates requires a wait for App Approval but the upside is that most users will have their app updated automatically by iOS. Sprints are now more structured as we aim to ship enhancements and new features at the end of our 2-week sprints.
Looking forward, there is much scope for improving the app to ensure the best experience for our users. Beside new features (we’re keeping them under wraps for now!), we have investigated adding support for iOS features such as Dynamic Type and Dark Mode. Now that our team is strong and the foundation of the mobile app solid, we’re looking forward to making the best iOS accounting app out there!