How to avoid a big refactor with the @_exported attribute

Sponsored

Zenitizer logo
Zenitizer - Meditation Timer

Enjoy clutter-free meditation with Zenitizer's clean UI and soothing sounds. For Apple geeks: Shortcuts, Apple Health, widgets and fully-featured watchOS and visionOS apps! Get 25% OFF with code: POL24

I have recently used Swift’s @_exported underscored attribute in a real-world project to minimise the impact of the changes I was making and keep the risk of introducing bugs to a minimum.

If you are not familiar with underscored attributes, you need to know that while they are part of the Swift language, they are not stable and are only intended to be used within the Swift monorepo as the documentation clearly states.

Despite this, I am going to show a use case where the @_exported attribute came in handy to reduce the number of files impacted by a refactor. I want to stress that this is not a recommended approach and should be used with caution.

The use case

Let’s say you are working on a modular application that uses a custom font and you have a Swift Package containing a Font enum which lazily loads the custom font resources and vends them to the rest of your app:

Font.swift
import UIKit

public enum Font {
    public static let regular: FontProvider = FontProvider(baseFont: FontFamily.MyAwesomeFont.regular)
    public static let medium: FontProvider = FontProvider(baseFont: FontFamily.MyAwesomeFont.medium)
    public static let bold: FontProvider = FontProvider(baseFont: FontFamily.MyAwesomeFont.bold)
}

public struct FontProvider {
    let baseFont: UIFont

    public func font(size: CGFloat, relativeToStyle style: UIFont.TextStyle) -> UIFont {
        let font = baseFont.font(size: size)
        return UIFontMetrics(forTextStyle: style).scaledFont(for: font)
    }

    // Text styles...
    var largeTitle: UIFont { font(size: 32, relativeToStyle: .largeTitle) }
    var title: UIFont { font(size: 28, relativeToStyle: .title1) }
}

The different font sizes are accessed by the rest of the app through a series of text styles defined in the FontProvider struct.

The views in your app can then import the Fonts Swift Package and use the Font enum directly to access the different text styles:

HomeViewController.swift
import Fonts
import UIKit

class HomeViewController: UIViewController {
    let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        label.text = "Hello World"
        label.font = Font.regular.largeTitle
        // ...
    }
}

Reusing the font loading code

This code works well but let’s now say you or another team in your company are working on a separate app that uses the same custom font but requires different text styles.

You can make the following changes to your existing Swift package to reuse the font-loading code:

  1. Remove all text styles from the FontProvider struct.
  2. Create a new shared Swift package with a different name (e.g. SharedFonts) and move the code in Fonts to the new package.
  3. Host the new Swift Package in a repository.
  4. Import the new package into any app that needs to use the custom font.
Font.swift
import UIKit

public enum Font {
    public static let regular: FontProvider = FontProvider(baseFont: FontFamily.MyAwesomeFont.regular)
    public static let medium: FontProvider = FontProvider(baseFont: FontFamily.MyAwesomeFont.medium)
    public static let bold: FontProvider = FontProvider(baseFont: FontFamily.MyAwesomeFont.bold)
}

public struct FontProvider {
    let baseFont: UIFont

    public func font(size: CGFloat, relativeToStyle style: UIFont.TextStyle) -> UIFont {
        let font = baseFont.font(size: size)
        return UIFontMetrics(forTextStyle: style).scaledFont(for: font)
    }
}

The SharedFonts package is now only responsible for loading and vending the custom fonts and the responsibility of defining the text styles is now on each of the clients using the library.

Using the new Swift Package without refactoring

In the previous section, we made the conscious decision of renaming the Fonts Swift Package to SharedFonts. This now allows us to keep our in-source package with the same name to avoid having to make changes to the existing imports across the app.

However, if you now build your application, you will get a bunch of compiler errors as the Font and FontProvider symbols are now part of the SharedFonts module and there are no text styles defined in the FontProvider struct.

To address this, you can replace all files in the in-source Fonts module with a single file that re-exports SharedFonts using the @_exported attribute and extends the FontProvider struct to define the app-specific text styles:

FontProvider+TextStyles.swift
import UIKit
// This is the new shared Swift Package
@_exported import SharedFonts

extension FontProvider {
    public var largeTitle: UIFont { font(size: 32, relativeToStyle: .largeTitle) }
    public var title: UIFont { font(size: 28, relativeToStyle: .title1) }
}

Doing an @_exported import means that whenever you import the local Fonts module in your app, you will also be importing the public interfaces from the SharedFonts Swift Package, which the views in your app are already using! 🎉

The implications of this seemingly small change are great, as you don’t have to make any changes to the views consuming the Font enum 🎉.

Proceed with caution

Although this approach works well for this specific use case, it is important to note that the use of the @_exported attribute is strongly discouraged outside of the Swift monorepo.

I decided to go with this approach to avoid having to make numerous changes to the existing codebase, hence reducing the risk of introducing bugs across all screens of a big application.