My new project: Tact, a simple chat app.

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.