オープンソースの作業をするのは久しぶりです。インディー開発者としてのキャリアを長期的に続けられるように、最近は新しいアプリの開発に集中していました。3 ヶ月で 6 つのアプリ をリリースした後、最もよく再利用しているコードを共有する時期だと考えました。そのためのオープンソースライブラリはすでにあります:HandySwift です。
しかし、最後のリリースから 2 年以上が経っていました。もちろん、アプリ間で簡単に再利用できるように、main ブランチには少しずつ機能を追加していました。ただ、新しいリリースに含まれておらず、ドキュメントもない機能は、API 自体が public でマークされていても「内部」扱いと考えられます。
そこで、ここ数日かけてすべてのコードを整理しました。Swift の最新の変更点との整合性を確保し、使われていないコードを削除し、リネームに対応する @available 属性を追加し(Xcode が修正候補を表示できるように)、多くの新しい API にドキュメントを書きました。さらに、まったく新しいロゴもデザインしました!
加えて、Swift-DocC を採用することにしました。これにより README ファイルを最小限にして、代わりに Swift Package Index のサイトでドキュメントをホストできるようになりました。ChatGPT の助けも借りて既存のドキュメントもさらに充実させ、今までリリースした中で最も充実したドキュメントのライブラリになりました!

移行の詳細については後日専用の記事を書く予定です。しかし、HandySwift について書いたことがなかったので、使うと得られる便利機能のいくつかを紹介させてください。すべてのプロジェクトに追加することをおすすめします。依存関係はなく、軽量で、すべてのプラットフォーム(Linux や visionOS を含む)をサポートし、iOS 12 まで遡って対応しています。迷う余地はないと思います。
Extensions
既存の型に追加された 100 以上の関数やプロパティの中から、実際のアプリで使った実用的なユースケースとともにハイライトをご紹介します:
安全なインデックスアクセス

FocusBeats では、インデックスを使って音楽トラックの配列にアクセスしています。subscript(safe:) を使うことで、範囲外アクセスによるクラッシュを回避しています:
var nextEntry: ApplicationMusicPlayer.Queue.Entry? {
guard let nextEntry = playerQueue.entries[safe: currentEntryIndex + 1] else { return nil }
return nextEntry
}Collection に準拠するすべての型(Array、Dictionary、String など)で使えます。通常の array[index] は非 Optional を返しますがインデックスが範囲外のときにクラッシュします。代わりに array[safe: index] を使えば、範囲外の場合はクラッシュせず nil を返します。
空白文字列 vs 空文字列

テキストフィールドが空でないことを要求する場面でよくある問題は、ユーザーが誤ってスペースや改行を入力しても気づかないことです。バリデーションコードが .isEmpty だけをチェックしていると、この問題は見逃されてしまいます。そのため TranslateKit では、ユーザーが API キーを入力した際に、まず文字列の先頭と末尾から改行やスペースを取り除いてから .isEmpty チェックをしています。これは多くの場所で頻繁に行う処理なので、ヘルパーを書きました:
Image(systemName: self.deepLAuthKey.isBlank ? "xmark.circle" : "checkmark.circle")
.foregroundStyle(self.deepLAuthKey.isBlank ? .red : .green).isEmpty の代わりに isBlank を使うだけで同じ動作が得られます!
読みやすい時間間隔

TimeInterval(Double の typealias)を受け取る API を使うたびに、単位がないためコードの可読性が下がることが気になっていました。「秒」であることを常に意識しなければならず、分や時間など別の単位が必要な場合は手動で計算する必要がありました。HandySwift があればその必要はありません!
素の Double 値(60 * 5 など)を渡す代わりに、.minutes(5) と書けます。たとえば TranslateKit でユーザーがサブスクリプションを解約した際のプレビューにはこう使っています:
#Preview("Expiring") {
ContentView(
hasPremiumAccess: true,
premiumExpiresAt: Date.now.addingTimeInterval(.days(3))
)
}+ 記号で複数の単位を連結して「午前 9 時 41 分」のような時刻を作ることもできます:
let startOfDay = Calendar.current.startOfDay(for: Date.now)
let iPhoneRevealedAt = startOfDay.addingTimeInterval(.hours(9) + .minutes(41))この API デザインは Duration や DispatchTimeInterval と統一されています。これらはすでに .milliseconds(250) のような書き方をサポートしていますが、秒までしか対応していません。HandySwift はこれらの型にも分、時間、日、さらには週を追加しています。なので、以下のようにも書けます:
try await Task.sleep(for: .minutes(5))⚠️ 時間間隔による進行は夏時間などの複雑な要素を考慮しません。そのような場合は
Calendarを使用してください。
平均値の計算

CrossCraft のクロスワード生成アルゴリズムでは、各イテレーションでパズル全体の品質を計算するヘルス関数があります。2 つの異なる側面が考慮されます:
/// 0 から 1 の間の値。
func calculateQuality() -> Double {
let fieldCoverage = Double(solutionBoard.fields) / Double(maxFillableFields)
let intersectionsCoverage = Double(solutionBoard.intersections) / Double(maxIntersections)
return [fieldCoverage, intersectionsCoverage].average()
}以前のバージョンでは異なる重み付けを試していました。たとえば、交差ポイントにフィールドカバレッジの 2 倍の重みを与えるなどです。average() を使えば、最終行を以下のようにするだけで実現できます:
return [fieldCoverage, intersectionsCoverage, intersectionsCoverage].average()浮動小数点数の丸め

CrossCraft でパズルを解いている間、画面上部に現在の進捗が表示されます。数値にはビルトインのパーセントフォーマッター(.formatted(.percent))を使っていますが、これは 0 から 1 の Double 値(1 = 100%)が必要です。Int で 12 を渡すと予期せず 0% と表示されるため、単純にこうすることはできません:
Int(fractionCompleted * 100).formatted(.percent) // => "0%" から "100%" までそして fractionCompleted.formatted(.percent) だけでは、"0.1428571429" のように非常に長い文字列になることがあります。
代わりに、rounded(fractionDigits:rule:) を使って Double を有効桁 2 桁に丸めています:
Text(fractionCompleted.rounded(fractionDigits: 2).formatted(.percent))ℹ️ 変数をその場で変更したい場合は、ミューテーティング版の
round(fractionDigits:rule:)関数もあります。
対称データ暗号化

CrossCraft でクロスワードパズルをアップロードする前に、テクノロジーに詳しい人が JSON から簡単に答えを盗み見できないように暗号化しています:
func upload(puzzle: Puzzle) async throws {
let key = SymmetricKey(base64Encoded: "<base-64 encoded secret>")!
let plainData = try JSONEncoder().encode(puzzle)
let encryptedData = try plainData.encrypted(key: key)
// アップロードロジック
}上記のコードでは 2 つの Extension を使っています。まず init(base64Encoded:) でキーを初期化し、次に encrypted(key:) で安全な CryptoKit API を内部的に使用してデータを暗号化します。細かいことを意識する必要はありません。
別のユーザーが同じパズルをダウンロードすると、decrypted(key:) で復号します:
func downloadPuzzle(from url: URL) async throws -> Puzzle {
let encryptedData = // ダウンロードロジック
let key = SymmetricKey(base64Encoded: "<base-64 encoded secret>")!
let plainData = try encryptedPuzzleData.decrypted(key: symmetricKey)
return try JSONDecoder().decode(Puzzle.self, from: plainData)
}ℹ️ HandySwift には
String用のencrypted(key:)とdecrypted(key:)関数もあり、暗号化データの base-64 エンコードされた文字列表現を返します。String API を扱う場合に使用してください。
新しい型
既存の型の拡張に加えて、HandySwift は 7 つの新しい型と 2 つのグローバル関数も導入しています。ほぼすべてのアプリで使っているものを紹介します:
GregorianDay と GregorianTimeOfDay
年、月、日から Date を作りたい? 簡単です:
GregorianDay(year: 1960, month: 11, day: 01).startOfDay() // => DateDate があって、時刻ではなく日付の部分だけを保存したい? モデルで GregorianDay を使うだけです:
struct User {
let birthday: GregorianDay
}
let selectedDate = // DatePicker から取得
let timCook = User(birthday: GregorianDay(date: selectedDate))
print(timCook.birthday.iso8601Formatted) // => "1960-11-01"今日の日付だけ、時刻なしで取得したい?
GregorianDay.today.yesterday や .tomorrow でも動作します。それ以上の操作は:
let todayNextWeek = GregorianDay.today.advanced(by: 7)ℹ️
GregorianDayはCodable、Hashable、Comparableなど期待されるプロトコルにすべて準拠しています。エンコード/デコードには「2014-07-13」のような ISO フォーマットを使用します。
GregorianTimeOfDay はその対になる型です:
let iPhoneAnnounceTime = GregorianTimeOfDay(hour: 09, minute: 41)
let anHourFromNow = GregorianTimeOfDay.now.advanced(by: .hours(1))
let date = iPhoneAnnounceTime.date(day: GregorianDay.today) // => DateDelay と Debounce
コードの遅延実行をしたいのに、この API が覚えにくく打ちづらいと感じたことはありませんか?
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(250)) {
// コード
}HandySwift ではもっと短く覚えやすい書き方ができます:
delay(by: .milliseconds(250)) {
// コード
}DispatchQueue と同様に、異なる Quality of Service クラスもサポートしています(デフォルトはメインキュー):
delay(by: .milliseconds(250), qosClass: .background) {
// コード
}遅延実行は一度きりのタスクには便利ですが、高速な入力がパフォーマンスやスケーラビリティの問題を引き起こすこともあります。たとえば、ユーザーが検索フィールドに素早く入力する場合です。検索結果の更新を遅らせ、新しい入力があった場合に古い入力をキャンセルするのは一般的なプラクティスで、「デバウンス」と呼ばれています。HandySwift を使えば簡単です:
@State private var searchText = ""
let debouncer = Debouncer()
var body: some View {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: self.$searchText)
.onChange(of: self.searchText) { newValue in
self.debouncer.delay(for: .milliseconds(500)) {
// ユーザーが 500 ミリ秒操作しなかった後に、更新された検索テキストで検索を実行
self.performSearch(with: newValue)
}
}
.onDisappear {
debouncer.cancelAll()
}
}Debouncer をプロパティに保存しているのは、消滅時にクリーンアップとして cancelAll() を呼べるようにするためです。しかし魔法が起きるのは delay(for:id:operation:) の部分で、細かいことを気にする必要はありません!

