Modernizing a 5-year-old UIKit app
January 26, 2022
I just finished modernizing a 5-year-old UIKit app, bringing it into the SwiftUI world and making other adjustments. It’s a simple content blocker app for Estonian media, and the new version is now available in the App Store.
There’s not that much visible change to the user. There’s a UI refresh, but otherwise, everything behaves pretty much the same. Internally and maintenance-wise, though, it’s a different story. I basically rewrote the whole app. Which is not that big of a deal as it sounds—it’s a very simple app. I always use such opportunities not only to work with the outwardly visible features, but also to test some new ideas and patterns and modern platform API. Following are my notes from this work, both as a reminder to myself, and perhaps useful to someone else too.
Old to new architecture
The starting point was a fairly typical UIKit app from a junior/mid level iOS developer (that would be myself, in 2015). This was the time way before SwiftUI, so it was all UIKit, with the UI implemented in a mix of storyboards and code. There was the usual assortment of app delegate, view controllers, model-object-like things. There were a few tests for the purchasing and receipt validation, but no tests for most of the app model and logic.
cloc reports this for the old state of the app.
151 text files.
148 unique files.
12 files ignored.
github.com/AlDanial/cloc v 1.90 T=0.12 s (1138.2 files/s, 376313.8 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
C/C++ Header 85 4706 9716 25965
XML 28 0 11 3034
Swift 20 562 493 1445
JSON 4 1 0 588
HTML 2 1 0 71
Markdown 2 7 0 17
-------------------------------------------------------------------------------
SUM: 141 5277 10220 31120
-------------------------------------------------------------------------------
Whoa. 25K lines of C/C++? What is that?
That would be OpenSSL headers. In the old world and old StoreKit, I need to do custom validation of the transaction receipts to ensure their integrity, and one way to do this is to build custom OpenSSL and bundle it with your app, to parse the needed data structures. Ugh. Either that, or use an external library or service. With StoreKit 2, I don’t need to do this—the needed verifications are part of the Apple SDK. I can still do it externally if needed, but in this app, I don’t have any reason to do that.
Okay. So that was the old app. How about the new one, after modernizing?
43 text files.
43 unique files.
6 files ignored.
github.com/AlDanial/cloc v 1.90 T=0.03 s (1328.4 files/s, 120006.0 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
XML 15 0 0 1454
Swift 16 289 192 911
JSON 3 2 0 472
HTML 2 4 0 85
Markdown 2 7 0 17
-------------------------------------------------------------------------------
SUM: 38 302 192 2939
-------------------------------------------------------------------------------
Overall reduction from 31K lines to 2.9K, over 90%. The big percentage is of course cheating since it previously was largely OpenSSL, but still—it was code living in the project that I needed to maintain.
XML has been greatly reduced. A big part of XML was storyboards that are now gone. The remaining XML is Xcode project files and configuration that cloc
for some reason reads as XML.
Swift has gone from 1400 lines to 911, with 114 of those being tests. Previously there were also some tests, but only for the purchasing and receipt validation part that’s now mostly handled by the system, nothing for the app itself.
Now, let’s look at the app entry point, which has greatly improved in clarity. I really like how SwiftUI nudges me to be clear about the app architecture and model. Here is the new app entry point in its entirety.
import SwiftUI
@main
struct PrillikiviApp: App {
@StateObject var buying = Buying()
@StateObject var filters = Filters()
@StateObject var preferences = Preferences()
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView(buying: buying, filters: filters, preferences: preferences)
}
.onChange(of: scenePhase) { scenePhase in
switch scenePhase {
case .active:
// When app becomes active, trigger loading new info
filters.loadInfotAtApplicationDidBecomeActive()
default:
break
}
}
}
}
I get a lot of insight in these 20 lines of code. I see that there are three model objects. They are separated and have no dependencies on one another. The view part of the app depends on all of them. The app checks for some kind of new information every time it becomes active.
I find this to be way more digestible than a mix of declarative storyboard structure and imperative UIKit code. There is no weird code and connections scattered around in view controllers, app delegate, and who knows where else. Sure, you could have this kind of clarity in the old world if you were disciplined. I freely admit that I’m not. I found it easy to be sloppy. The new tooling nudges me greatly towards discipline.
Making StateObjects aware of each other’s state
In reality, of course, there is a lot more nuance than shown above. I separated the model objects to be clear about their different responsibilities. But they have dependencies. Filters
deals with actually loading and applying the content filters that are the heart of my app. Buying
deals with everything related to the app purchasing and subscription. Filters
obviously needs to know what is the state of the purchase and whether the user has a valid subscription to the app, because the available filters depend on that. How would you model this?
In the old world, I’d consider solutions like putting everything into one big model object, or Buying
broadcasting notifications and Filter
listening to those, or making the models be aware of entire other model objects by making them weakly-held properties on one another, or using the delegate pattern, or many other tools.
In this project, I reached out to Combine
, which is already implicitly used when you use SwiftUI, and you can further leverage for your own benefit. This StackOverflow question showed me idea.
Using AnyPublisher decouples the idea of having a specific types for either side of the equation, so it would be just as easy to connect ViewModel4 to ViewModel1, etc.
Here is my recipe of making Filters
aware of the purchased state of Buying
.
@MainActor
class Buying: ObservableObject {
@Published private(set) var currentPurchaseExpiration: Date?
}
@MainActor
class Filters: ObservableObject {
private var purchaseCancellable: AnyCancellable?
func connectToPurchased(_ publisher: AnyPublisher<Date?, Never>) {
purchaseCancellable = publisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink(receiveValue: { newDate in
// Do something with the received date (note it’s Optional)
})
}
}
struct ContentView: View {
var body: some View {
WhateverContentView()
.onAppear {
filters.connectToPurchased(buying.$currentPurchaseExpiration.eraseToAnyPublisher())
}
}
}
Buying
isn’t aware of any connections. It just exposes a regular @Published
property for SwiftUI. But this contains a whole CurrentValueSubject
Combine subject inside, which we will use.
Filters
creates a Combine subscriber to the purchased date, but isn’t aware of where exactly it comes from.
ContentView
connects the two sides, by grabbing the published property from Buying
, and passing it to Filters
. Since the published property contains a CurrentValueSubject
, the initial value is also sent immediately upon making the connection. You could argue that it shouldn’t be a view making this connection, but rather another model-like thing or the top-level app structure, and you’d probably be right.
This setup affords great testability. So let’s say we want to test some behavior of Filters
that depends on some value of the purchased date being received. Here’s how.
func testPurchasedFilters() {
let filters = Filters()
filters.connectToPurchased(Just(Date().advanced(by: 86400)).eraseToAnyPublisher())
let expectation = XCTestExpectation(description: "Wait for filters connection")
Task {
let categories = filters.categories
let expected: Set<FilterCategory> = … some expected value
XCTAssertEqual(categories, expected)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}
In a test, we use Just
to create an immediate publisher from a simple value, without any Buying
object being present. From Filters
perspective, everything works all the same, it just receives a value. I am creating a Task
for asynchronous execution, since all this value propagation and publishing doesn’t all seem to happen immediately on the same thread.
I feel that with this setup, there is “just enough” amount of connection between the objects. They can get the initial values of specific properties, and be aware of changes to those, without having to know anything else about each other’s internal structure, and without having to create extra protocols or any other glue.
Running only one task at a time
Another interesting problem I dealt with was, how to run only one task of a given type at a time? In my app, it would be something like downloading filter content from iCloud, which I use to distribute the filters. You see above how some info is loaded every time the app becomes active. The user may repeatedly do this, and I do not want to do this if there already is such work in progress.
Here’s an article by John Sundell that covers the basics of how this works in the new Swift concurrency world. Although most of the article is about actors and data isolation, towards the end it also provides a recipe of how to set up the tasks so that only task of a given kind is running at a time. I just took that code, simplified, and ended up with this.
@MainActor
class Filters: ObservableObject {
private var downloaderTask: Task<FilterDownloadResult, Never>?
private func downloadNewFiltersFromCloudKit() async -> FilterDownloadResult {
if let existingDownloaderTask = downloaderTask {
return await existingDownloaderTask.value
}
let newDownloaderTask = Task<FilterDownloadResult, Never> {
guard someCheck else {
downloaderTask = nil
return .error
}
// Do work to get the result
downloaderTask = nil
return .someResult
}
downloaderTask = newDownloaderTask
return await newDownloaderTask.value
}
}
I can now call downloadNewFiltersFromCloudKit
many times, and there’s only up to one instance of the task ever running.
It lets me quite neatly express what I want to do. The one weak point visible here is that at each site of returning a result, I need to remember to nil out the reference to the task. I also haven’t thought through how error handling and throwing works together with tasks. In this task, I handle all the errors inline and just return a different result to indicate an error. But I could also use native Swift error handling that throws the errors, and I haven’t yet examined how that works with tasks.
Summary
I took an old project and re-wrote it with some modern technologies (SwiftUI, Combine, modern concurrency, StoreKit 2). Next up: perhaps another modernization in another 5 years’ time.