[Xcode/Swift] iOS FileManager と URL設計思想 ②

この記事から、URLResourceValues、SSZipArchive、ZIPFoundationを軸に解説をしていきます。

URLResourceValues:ファイルのメタデータを正しく取得する

ファイルのサイズを調べたい。ディレクトリかどうか判断したい。作成日時を取得したい。

こういうとき、昔は stat() システムコールを使ったり、NSFileManager の attributes API を使っていました。

// 古い方法
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let fileSize = attrs[.size] as? Int64
let isDirectory = (attrs[.type] as? FileAttributeType) == .typeDirectory

これは今も動きますが、 URLResourceValues という modern な方法が今はあります。

let fileURL = documentsURL.appendingPathComponent("sample.zip")

// 取得したいキーを指定
let resourceKeys: Set<URLResourceKey> = [
    .isDirectoryKey,
    .fileSizeKey,
    .creationDateKey,
    .contentModificationDateKey
]

// メタデータを取得
let resourceValues = try fileURL.resourceValues(forKeys: resourceKeys)

// プロパティにアクセス
let isDirectory = resourceValues.isDirectory ?? false
let fileSize = resourceValues.fileSize ?? 0
let creationDate = resourceValues.creationDate

なぜ forKeys を指定するのか

resourceValues(forKeys:) は 「取得したいキーだけを指定する」 設計になっており、これが重要なポイントとなります。

ファイルシステムのメタデータには非常に多くの情報が含まれています。作成日時、更新日時、ファイルサイズ、パーミッション、拡張属性、iCloud 同期状態……

これを全部一度に取得するのはコストが高いため、forKeys で必要なものだけを指定することで、ファイルシステムアクセスを最小限にできます

これは Apple の API 設計のよくある思想で、「欲しいものを宣言的に指定する」「実行コストを呼び出し側が制御できる」という部分になります。

contentsOfDirectory との組み合わせ

contentsOfDirectory(at:includingPropertiesForKeys:) に URLResourceKey を渡すと、ディレクトリ列挙と同時にメタデータをプリフェッチできるようになります。

let resourceKeys: Set<URLResourceKey> = [.isDirectoryKey, .fileSizeKey]

let contents = try FileManager.default.contentsOfDirectory(
    at: documentsURL,
    includingPropertiesForKeys: Array(resourceKeys),
    options: [.skipsHiddenFiles]
)

for url in contents {
    let values = try url.resourceValues(forKeys: resourceKeys)
    // この時点でキャッシュ済みなので追加のファイルシステムアクセスは発生しない
    if values.isDirectory == true {
        print("Directory: \(url.lastPathComponent)")
    } else {
        let size = values.fileSize ?? 0
        print("File: \(url.lastPathComponent) (\(size) bytes)")
    }
}

includingPropertiesForKeys に指定したキーはディレクトリ列挙時にまとめて取得されキャッシュされる。その後 resourceValues(forKeys:) を呼んでも追加のファイルシステムアクセスが発生しない。これがパフォーマンス上の大きなメリットです。

URLResourceValues でよく使うキー一覧

キー説明
.isDirectoryKeyBool?ディレクトリかどうか
.isRegularFileKeyBool?通常ファイルかどうか
.fileSizeKeyInt?ファイルサイズ(バイト)
.totalFileSizeKeyInt?リソースフォーク含む合計サイズ
.creationDateKeyDate?作成日時
.contentModificationDateKeyDate?最終更新日時
.isHiddenKeyBool?隠しファイルかどうか
.isUbiquitousItemKeyBool?iCloud アイテムかどうか
.nameKeyString?ファイル名

SSZipArchive:Objective-C 時代の遺産

ZIP 解凍ライブラリといえば、長年 SSZipArchive が使われてきました。

SSZipArchive は Objective-C で書かれた古いライブラリで、CocoaPods や Swift Package Manager 経由で今でもよく使われており、古いと言っても、安定性と実績という点では間違いなく信頼できるツールとなっています。

SSZipArchive の基本的な使い方

import SSZipArchive

// ZIP ファイルを解凍する
let zipURL = Bundle.main.url(forResource: "images", withExtension: "zip")!
let destinationURL = FileManager.default.temporaryDirectory
    .appendingPathComponent("unzipped", isDirectory: true)

// ここが重要:URL ではなく String path を渡す必要がある
// 元々NSString * を受け取るように設計されていて、Objective-C の時代にファイルパスは文字列だった
let success = SSZipArchive.unzipFile(
    atPath: zipURL.path,           // URL → String path に変換
    toDestination: destinationURL.path  // 同様に変換
)

if success {
    print("解凍成功")
} else {
    print("解凍失敗")
}

パスワード付き ZIP の解凍

SSZipArchive.unzipFile(
    atPath: zipURL.path,
    toDestination: destinationURL.path,
    overwrite: true,
    password: "secret",
    error: nil
)

進捗を取得したい場合(Delegate)

class MyViewController: UIViewController, SSZipArchiveDelegate {
    
    func extractZip() {
        SSZipArchive.unzipFile(
            atPath: zipURL.path,
            toDestination: destinationURL.path,
            delegate: self
        )
    }
    
    // 進捗コールバック(Objective-C スタイル)
    // delegate パターンも Objective-C 時代の設計そのまま。modern Swift の async/await とは相性が悪い。

    func zipArchiveProgressEvent(_ loaded: UInt64, total: UInt64) {
        let progress = Double(loaded) / Double(total)
        print("進捗: \(Int(progress * 100))%")
    }
}

ZIPFoundation:Swift-native な ZIP ライブラリ

一方、ZIPFoundation は Swift で書かれた ZIP ライブラリで、URL ベースの API を提供しています。

import ZIPFoundation

// ZIP ファイルを解凍する
// FileManager の extension として実装されているため、自然に FileManager と組み合わせて使える
let zipURL = Bundle.main.url(forResource: "images", withExtension: "zip")!
let destinationURL = FileManager.default.temporaryDirectory
    .appendingPathComponent("unzipped", isDirectory: true)

do {
    try FileManager.default.createDirectory(
        at: destinationURL,
        withIntermediateDirectories: true
    )
    try FileManager.default.unzipItem(at: zipURL, to: destinationURL)
    print("解凍成功")
} catch {
    print("解凍失敗: \(error)")
}

Archive を直接操作する

import ZIPFoundation

// ZIP ファイルの中身を列挙する
let zipURL = Bundle.main.url(forResource: "images", withExtension: "zip")!

// Archive(url:accessMode:) は URL を直接受け取るためString path への変換が不要
guard let archive = Archive(url: zipURL, accessMode: .read) else {
    print("アーカイブを開けませんでした")
    return
}

for entry in archive {
    print("エントリ: \(entry.path), サイズ: \(entry.uncompressedSize) bytes")
}

SSZipArchive vs ZIPFoundation 比較

項目SSZipArchiveZIPFoundation
API スタイルString path ベースURL ベース
言語Objective-CSwift
エラーハンドリングBool 戻り値 / error ポインタthrows
Swift らしさ
async/await 相性△(wrap 必要)
実績・安定性◎(長年の使用実績)○(近年増加)
Objective-C 互換
パスワード付き ZIP
ZIP エントリ操作
ファイルサイズ(バイナリ)やや大きいコンパクト

どっちを選ぶか?

  • 既存の ObjC プロジェクト、パスワード付き ZIP が必須、実績重視 → SSZipArchive
  • 新規 Swift プロジェクト、async/await を使いたい、Swift らしく書きたい → ZIPFoundation

簡単なサンプルアプリを作って挙動確認

images.zipは適当な画像をzipにして、プロジェクトに配置しておいてください。

import SwiftUI
import ZipArchive

struct ContentView: View {
    // 解凍して見つかった画像のUIImageを格納する配列
    @State private var loadedImages: [UIImage] = []
    @State private var isProcessing = false
    @State private var statusMessage = "ZIPファイルを解凍していません"

    var body: some View {
        VStack {
            Text(statusMessage)
                .font(.subheadline)
                .foregroundColor(.gray)
                .padding()

            if isProcessing {
                ProgressView("解凍中...")
            } else if !loadedImages.isEmpty {
                // 解凍された画像がある場合はリストで表示
                ScrollView {
                    LazyVStack(spacing: 15) {
                        ForEach(0..<loadedImages.count, id: \.self) { index in
                            Image(uiImage: loadedImages[index])
                                .resizable()
                                .scaledToFit()
                                .frame(maxHeight: 200)
                                .cornerRadius(10)
                                .shadow(radius: 3)
                                .padding(.horizontal)
                        }
                    }
                }
            } else {
                Spacer()
            }

            Button(action: {
                // 非同期(Task)で解凍処理を実行
                Task {
                    do {
                        // バックグラウンドで処理、完了を await で待つ
                        let loadedImages = try await unzipAndLoadImages(zipName: "images")
                        await MainActor.run {
                            self.loadedImages = loadedImages
                        }

                    } catch {
                        // エラー処理
                    }
                }
            }) {
                Text("ZIPを解凍して画像を表示")
                    .bold()
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(isProcessing ? Color.gray : Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            .disabled(isProcessing)
            .padding()
        }
    }

    /// ZIPファイルを解凍して画像を読み込む関数
    /// Bundle内のZIPを解凍し、含まれる画像を読み込む
    /// - Parameter zipName: ZIP ファイル名(拡張子なし)
    /// - Returns: 読み込んだ UIImage の配列
    func unzipAndLoadImages(zipName: String) async throws -> [UIImage] {
        isProcessing = true

        // 1. Bundle から ZIP の URL を取得
        guard let zipURL = Bundle.main.url(forResource: zipName, withExtension: "zip") else {
            throw AppError.zipNotFound(zipName)
        }

        // 2. 一時ディレクトリに解凍先を作成(UUID でユニークにする)
        let temporaryDirectory = FileManager.default.temporaryDirectory
        let destinationURL = temporaryDirectory
            .appendingPathComponent(UUID().uuidString, isDirectory: true)

        // 3. 解凍先ディレクトリを作成
        try FileManager.default.createDirectory(
            at: destinationURL,
            withIntermediateDirectories: true,
            attributes: nil
        )

        // 4. 処理後に一時ディレクトリを必ず削除する
        defer {
            try? FileManager.default.removeItem(at: destinationURL)
        }

        // 5. バックグラウンドスレッドで重い処理を実行
        let images: [UIImage] = try await Task.detached(priority: .userInitiated) {

            // 6. SSZipArchive で解凍(URL.path で String path に変換)
            let success = SSZipArchive.unzipFile(
                atPath: zipURL.path,
                toDestination: destinationURL.path
            )

            guard success else {
                throw AppError.unzipFailed
            }

            // 7. 解凍されたファイルを列挙する
            guard let enumerator = FileManager.default.enumerator(
                at: destinationURL,
                includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
                options: [.skipsHiddenFiles, .skipsPackageDescendants]
            ) else {
                return []
            }

            var images: [UIImage] = []

            // 8. 各ファイルを処理
            while let fileURL = enumerator.nextObject() as? URL {

                // 9. URLResourceValues でメタデータを確認
                let resourceValues = try fileURL.resourceValues(
                    forKeys: [.isRegularFileKey, .fileSizeKey]
                )

                guard resourceValues.isRegularFile == true else { continue }

                // 10. 画像ファイルかどうかを拡張子で判断
                let imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "heic"]
                let ext = fileURL.pathExtension.lowercased()
                guard imageExtensions.contains(ext) else { continue }

                // 11. ファイルサイズのチェック(大きすぎるファイルをスキップ)
                let fileSize = resourceValues.fileSize ?? 0
                guard fileSize > 0, fileSize < 50_000_000 else { continue }  // 50MB 以下

                // 12. データを読み込んで UIImage に変換
                let data = try Data(contentsOf: fileURL)
                if let image = UIImage(data: data) {
                    images.append(image)
                }
            }

            return images
        }.value

        isProcessing = false
        statusMessage = "Zipファイル解凍が正常に処理されました"
        return images
    }

    // エラー定義
    enum AppError: Error, LocalizedError {
        case zipNotFound(String)
        case unzipFailed

        var errorDescription: String? {
            switch self {
            case .zipNotFound(let name):
                return "ZIP ファイルが見つかりません: \(name).zip"
            case .unzipFailed:
                return "ZIP 解凍に失敗しました"
            }
        }
    }
}

まとめ

  • URLResourceValues取得したいキーだけを指定する
  • SSZipArchiveもまだ現役で使える
  • ただasync/awaitなりSwiftスタイルに書いていきたいならZipFoundationを採用するのがベター