肇鑫的技术博客

业精于勤,荒于嬉

Swift Packages Quick Learning

So far, I have learnt four ways of adding frameworks, manually, CocoaPods, Carthage and Swift Package Manager. This article is a summary of how to creating your own Swift Packages.

What is Swift Package?

A Swift Package is a bundle of source files with a meaningful name.

Platform

If unspecified, Swift Package works in all versions of Apple Operation Systems, this make a lot of version warnings. So a better idea is to provide the platform your package could work with.

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyAppStore",
    platforms: [
        .macOS(.v11)
    ],

Dependencies

Adding dependencies is easy. However, you must added the dependencies to both targets and test targets.

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyAppStore",
    platforms: [
        .macOS(.v11)
    ],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "MyAppStore",
            targets: ["MyAppStore"]),
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(url: "git@github.com:owenzhao/QRCodeKit.git", Package.Dependency.Requirement.branch("1.0.0")),
        .package(url: "git@github.com:weichsel/ZIPFoundation.git", "0.9.12"..<"1.0.0")
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "MyAppStore",
            dependencies: ["QRCodeKit", "ZIPFoundation"]),
        .testTarget(
            name: "MyAppStoreTests",
            dependencies: ["MyAppStore", "QRCodeKit", "ZIPFoundation"]),
    ]
)

Versioning

For versioning, there is a trick. There is no where in the swift part to set the version of your package. The version is set by git tag. So you need to do the versioning in your git tool.

swift_package_versioning

Resources

Swift Packages can get some kinds of sources as resources automatically, but you may also need to add sources manually.

You can add them as files one by one. Or you can add the folder directly. If you use process, Xcode maybe take further optimization to the resources. If you want to keep them unchanged, you can use copy.

    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "MyAppStore",
            dependencies: ["QRCodeKit", "ZIPFoundation"],
            resources: [
                .process("icons"),
                .process("AllApps.zip")
            ]),

Localization

Add default localizedation in Package.swift

let package = Package(
    name: "MyAppStore",
    defaultLocalization: "en",
    platforms: [
        .macOS(.v11),
        .iOS(.v14)
    ],

You must use genstrings to get localized strings.

  1. Go to the directory of swift files.
  2. Create a directory named en.lpproj.
  3. Run code find ./ -name "*.swift" -print0 | xargs -0 genstrings -o en.lproj.
  4. Copy en.lpproj as the name of you intent, say zh.lpproj.
  5. Translate the localized strings under zh.lpproj.
$ tree
.
├── AppInfoSwiftUIView.swift
├── MainSwiftUIView.swift
├── en.lproj
│   └── Localizable.strings
└── zh.lproj
      └── Localizable.strings

Using String resources in Swift Packages

Swift Package is built alone with the main bundle, it is called module Bundle, so you have to explicitly tell the bundle by name, or the system will try to find the string resources in the main bundle.

Button(action: download, label: {
    Text("Download", bundle: .module)
})

You should also notice that there are still some subtle issues. I found that the lang.lpproj directories must be under the target directory directly, or the preview won't take the environment effect.
However, running app goes fine whenever the directory goes.

References

SwiftUI Preview Crashed Issue With Exception `RLMException`. Reason: This method may only be called on RLMArray instances retrieved from an RLMRealm.

After investigation, I found that this issue was caused by sorted method with keyPath.

List { // this list crashes
    ForEach(dayList.items.sorted(by: \.startDate, ascending: true)) { currentItem in
        ItemRowSwiftUIView(currentItem: currentItem)
    }.onDelete(perform: { indexSet in
        removeItems(atOffsets: indexSet)
    })
}
    
List { // this list doesn't crash
    ForEach(dayList.items.sorted(by: {
        $0.startDate < $1.startDate
    })) { currentItem in
        ItemRowSwiftUIView(currentItem: currentItem)
    }.onDelete(perform: { indexSet in
        removeItems(atOffsets: indexSet)
    })
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(dayList: DayList())
    }
}

When using the keyPath to sort, the preview crashed. That because sorted by keyPath used realm and for a unmanaged object dayList, there is no realm.

So we need to create a realm first.

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(dayList: createRealmAndGetDayList())
    }

    static func createRealmAndGetDayList() -> DayList {
        let realm = try! Realm(configuration: Realm.Configuration(inMemoryIdentifier: "for debug"))
        let dayList = DayList()
        
        try! realm.write({
            realm.add(dayList, update: .all)
        })

        return dayList
    }
}

Then everything will work.

Trump Realm With SwiftUI

You may have already known how to using Realm with Swift. In case you were not, we would like to have a quick review.

Realm With Swift

Model

import Foundation
import RealmSwift

class Item:Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var title = ""
    @Persisted(originProperty: "items") var group: LinkingObjects<Group>
}

class Group:Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var items = RealmSwift.List<Item>()
}

Get Results

let realm = try! Realm()
let groups = realm.objects(Group.self)

Mutating Data

try! realm.write {
    let group = Group()
    realm.add(group, update: .all)
    group.items.append(Item())
}

Realm With SwiftUI

Model in Realm with SwiftUI is the same. However, how to getting results and mutating data are much different.

Get Results

Results are got implicitly.

import SwiftUI
import RealmSwift

struct ContentView: View {
    @ObservedResults(Group.self) var groups
    
    var body: some View {
        if let group = groups.last {
            MainSwiftUIView(group: group)
        } else {
            ProgressView().onAppear(perform: {
                $groups.append(Group())
            })
        }
    }
}

You can think @ObservedResults as some kind of Binding struct, its wrappedValue is the original Realm results.

public var wrappedValue: RealmSwift.Results<ResultType> { get }

When you call @ObservedResults(Group.self) var groups, Realm automatically created implicitly.

public init(_ type: ResultType.Type, configuration: RealmSwift.Realm.Configuration? = nil, filter: NSPredicate? = nil, keyPaths: [String]? = nil, sortDescriptor: RealmSwift.SortDescriptor? = nil)

Mutating Data

@ObservedResults(Group.self) var groups

The variable groups and $groups are different. groups is a frozen object of Group. While $groups is a ObservedResults object, it is unfrozen and is used to do mutation.

Frozen object mean the state of the object is unchanged, the normal Realm object is live and unfrozen.

private func add() {
    $group.items.append(Item())
}

@ObservedRealmObject

@ObservedRealmObject is similar like @ObservedResults, but it is not results, but for single realm object.

You should take care of the spelling. As there are a lot similar spellings. Like @ObservableObject, @ObservedObject, they all look like @ObservedRealmObject, if you misspell them, the compiler won't notify and you app won't work.
Also, there are two common naming space issues. RealmSwift.List<T> and SwiftUI.App.

References